Compare commits

...
4 Commits
Author SHA1 Message Date
DenozordecandCursor 73f5380768 fix(db): собрать listLatestLiveHealthByServiceIds в dist
CD / update-wiki (push) Successful in 7s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 1m13s
CD / quality (push) Successful in 2m7s
CD / publish (push) Successful in 2m55s
Очередь health-check видит собранную функцию и больше не падает.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 18:39:40 +07:00
DenozordecandCursor 0ef57c673c feat(web): перенести оболочку и карточки на дизайн CF Domain Manager
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m29s
quality / api (push) Successful in 1m10s
CD / quality (push) Successful in 2m54s
CD / publish (push) Successful in 42s
Собрать панель, списки и настройки в одной оболочке, чтобы статусы, очередь и свежесть данных были видны на доменах, сервисах и группах.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 18:33:27 +07:00
DenozordecandCursor b078fa0a03 fix(health): не показывать Down при выключенном health-check
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m58s
CD / publish (push) Successful in 1m25s
Маскируем устаревший down, если HC выкл (binding/группа), IP или сервис отключены.
Бейдж Disabled — нейтральный, не красный.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:46:37 +07:00
DenozordecandCursor 75575a3243 fix(dns): искать записи в Cloudflare по имени и содержимому
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 7s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 48s
CD / quality (push) Successful in 58s
CD / publish (push) Successful in 1m35s
cf_record_id только как кэш-подсказка: при удалении/обновлении
сначала list + match type/name/content, иначе create или no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:19:16 +07:00
57 changed files with 4108 additions and 1467 deletions

No files matched your search

+139 -41
View File
@@ -1,11 +1,17 @@
import type { Db } from "@cfdm/db";
import { repos, type DnsListFilter } from "@cfdm/db";
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
import type {
CfDnsRecord,
CreateDnsRecordPayload,
DnsRecord,
PatchDnsRecordPayload,
} from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
@@ -69,6 +75,46 @@ function isMissingCfDnsRecord(error: unknown): boolean {
return /record does not exist|81044/i.test(message);
}
function dnsContentMatches(
recordType: string,
left: string,
right: string,
): boolean {
if (recordType.toUpperCase() === "CNAME") {
return (
left.trim().replace(/\.+$/, "").toLowerCase() ===
right.trim().replace(/\.+$/, "").toLowerCase()
);
}
return left === right;
}
/** Resolve live Cloudflare record by name + type + content (IP / CNAME target). */
function findRemoteByIdentity(
remote: readonly CfDnsRecord[],
zoneName: string,
recordType: string,
name: string,
content: string,
): CfDnsRecord | undefined {
const type = recordType.toUpperCase();
return remote.find(
(record) =>
Boolean(record.id) &&
(record.type ?? "").toUpperCase() === type &&
dnsRecordNamesMatch(record.name, name, zoneName) &&
dnsContentMatches(type, record.content, content),
);
}
function findRemoteByCfId(
remote: readonly CfDnsRecord[],
cfRecordId: string | null | undefined,
): CfDnsRecord | undefined {
if (!cfRecordId) return undefined;
return remote.find((record) => record.id === cfRecordId);
}
async function markSynced(
db: Db,
domainId: number,
@@ -99,6 +145,11 @@ async function markSynced(
return repos.getDnsRecord(db, domainId, record.id);
}
/**
* Push local desired state to Cloudflare.
* Identity is name + type + content; cf_record_id is only a cache hint
* (records may be deleted/recreated outside CFDM).
*/
async function pushRecord(
db: Db,
cf: CloudflareClient,
@@ -106,6 +157,7 @@ async function pushRecord(
cfZoneId: string,
record: DnsRecord,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const payload = toCfPayload(
record.record_type,
record.name,
@@ -116,13 +168,24 @@ async function pushRecord(
);
try {
const cfRec = record.cf_record_id
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
const remote = await cf.listDnsRecords(cfZoneId);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
const cfRec = targetId
? await cf.updateDnsRecord(cfZoneId, targetId, payload)
: await cf.createDnsRecord(cfZoneId, payload);
return markSynced(db, domainId, record, cfRec);
} catch (e) {
// Stale cf_record_id after manual CF edits / prior buggy sync — recreate.
if (record.cf_record_id && isMissingCfDnsRecord(e)) {
// Race: cached id vanished mid-flight — recreate by identity.
if (isMissingCfDnsRecord(e)) {
try {
const created = await cf.createDnsRecord(cfZoneId, payload);
return markSynced(db, domainId, record, created);
@@ -225,15 +288,23 @@ export async function patchContent(
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const existing = repos.getDnsRecord(db, domainId, recordId);
if (!existing.cf_record_id) {
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
}
try {
const cfRec = await cf.patchDnsRecord(
domain.cf_zone_id,
existing.cf_record_id,
payload,
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
existing.record_type,
existing.name,
existing.content,
);
const byCachedId = findRemoteByCfId(remote, existing.cf_record_id);
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
if (!targetId) {
throw AppError.dnsUpdateFailed(
"DNS-запись не найдена в Cloudflare по имени и содержимому",
);
}
const cfRec = await cf.patchDnsRecord(domain.cf_zone_id, targetId, payload);
repos.updateDnsFields(
db,
existing.id,
@@ -244,7 +315,7 @@ export async function patchContent(
cfRec.proxied ?? existing.proxied,
cfRec.priority ?? existing.priority,
SYNC_SYNCED,
cfRec.id ?? existing.cf_record_id,
cfRec.id ?? targetId,
null,
);
return repos.getDnsRecord(db, domainId, existing.id);
@@ -272,20 +343,41 @@ export async function deleteRecord(
const record = repos.getDnsRecord(db, domainId, recordId);
repos.markDnsPendingDelete(db, recordId);
if (record.cf_record_id) {
let targetId: string | null = null;
try {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
targetId = byIdentity?.id ?? byCachedId?.id ?? null;
} catch {
// Zone list failed — fall back to cached id only.
targetId = record.cf_record_id;
}
if (targetId) {
try {
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
await cf.deleteDnsRecord(domain.cf_zone_id, targetId);
} catch (e) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
record.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e;
// Already gone in Cloudflare (manual delete) — drop local row.
if (!isMissingCfDnsRecord(e)) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
targetId,
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
}
repos.deleteDnsRecord(db, recordId);
}
@@ -363,24 +455,30 @@ export async function resolveConflict(
}
if (req.source === "cloudflare") {
if (record.cf_record_id) {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const r = remote.find((x) => x.id === record.cf_record_id);
if (r) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
const r = byIdentity ?? byCachedId;
if (r) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
return repos.getDnsRecord(db, domainId, recordId);
}
@@ -546,6 +546,34 @@ function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
return "unknown";
}
/** Health badge applies only when the service, IP and HC (binding or group) are active. */
function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean {
if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) {
return true;
}
if (!view.service_group_id) return false;
const group = repos.getServiceGroup(db, view.service_group_id);
return Boolean(group.health_check_enabled);
}
function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean {
if (!view.enabled) return false;
if (view.ip_enabled[ip] === false) return false;
return isServiceHealthCheckActive(db, view);
}
function inactiveIpHealthRow(ip: string): ServiceHealthRow {
return {
ip,
status: "unknown",
latency_ms: null,
last_checked_at: null,
last_error: null,
provider: "local",
colo: null,
};
}
function attachServiceHealth(
db: Db,
views: ServiceView[],
@@ -567,6 +595,9 @@ function attachServiceHealth(
),
);
const ip_health = (view.ips ?? []).map((ip) => {
if (!isIpHealthMonitored(db, view, ip)) {
return inactiveIpHealthRow(ip);
}
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
const live = liveByIp.get(ip);
const status = overlayLiveHealth(row?.status, live?.status);
@@ -584,17 +615,26 @@ function attachServiceHealth(
colo: extras?.colo ?? null,
};
});
const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));
const monitoredStatuses = ip_health
.filter((row) => isIpHealthMonitored(db, view, row.ip))
.map((row) => row.status);
const displayStatus =
monitoredStatuses.length > 0
? bestAliveDisplayStatus(monitoredStatuses)
: ("unknown" as const);
const latencyRow =
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
ip_health.find((row) => row.latency_ms != null);
return {
...view,
health_status: overlayLiveHealth(health?.health_status, displayStatus),
health_status:
monitoredStatuses.length > 0
? overlayLiveHealth(health?.health_status, displayStatus)
: "unknown",
health_latency_ms:
displayStatus !== "unknown"
monitoredStatuses.length > 0 && displayStatus !== "unknown"
? (latencyRow?.latency_ms ?? null)
: (health?.health_latency_ms ?? null),
: null,
ip_health,
};
});
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { SYNC_SYNCED } from "@cfdm/shared";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import * as dnsService from "../src/services/dns-service.js";
import { updateConfig } from "../src/services/service-config-service.js";
type CfRec = {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
};
function setupDb() {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
return db;
}
describe("DNS identity by name + content", () => {
it("deletes by name+IP when cached cf_record_id is stale/missing in CF", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-live",
type: "A",
name: "nsgt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async () => {
throw new Error("create should not run");
},
updateDnsRecord: async () => {
throw new Error("update should not run");
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
deleted.push(id);
const idx = remote.findIndex((r) => r.id === id);
if (idx >= 0) remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const local = repos.insertDnsRecord(
db,
domain.id,
"A",
"nsgt.example.com",
"130.49.213.176",
1,
false,
null,
SYNC_SYNCED,
"local",
"cf-stale-gone", // not present in Cloudflare
);
await dnsService.deleteRecord(db, cf, domain.id, local.id);
expect(deleted).toEqual(["cf-live"]);
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
expect(remote).toEqual([]);
});
it("delete is no-op success when record already removed outside CFDM", async () => {
const db = setupDb();
const cf = {
listDnsRecords: async () => [],
deleteDnsRecord: async () => {
throw new Error("Record does not exist");
},
verifyToken: async () => true,
listZones: async () => [],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const local = repos.insertDnsRecord(
db,
domain.id,
"A",
"nsgt.example.com",
"130.49.213.176",
1,
false,
null,
SYNC_SYNCED,
"local",
"cf-already-gone",
);
await expect(
dnsService.deleteRecord(db, cf, domain.id, local.id),
).resolves.toBeUndefined();
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
});
it("updateConfig can remove extra FQDN when CF id is stale", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-gt",
type: "A",
name: "gt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
{
id: "cf-nsgt-live",
type: "A",
name: "nsgt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async (
_zoneId: string,
payload: { type: string; name: string; content: string },
) => {
const rec: CfRec = {
id: `cf-new-${remote.length}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote.push(rec);
return rec;
},
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => {
const idx = remote.findIndex((r) => r.id === id);
if (idx < 0) throw new Error("Record does not exist");
const rec: CfRec = {
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote[idx] = rec;
return rec;
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
const idx = remote.findIndex((r) => r.id === id);
if (idx < 0) throw new Error("Record does not exist");
deleted.push(id);
remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
],
} as unknown as CloudflareClient;
repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Main TG", "tg-pr");
repos.setServiceEnabled(db, service.id, true);
await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [
{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] },
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
],
});
// Poison cached id on extra binding's DNS row.
const nsgtBinding = repos
.listBindingsByService(db, service.id)
.find((b) => b.hostname === "nsgt")!;
const nsgtRecords = repos.listRecordsForBinding(db, nsgtBinding.id);
for (const row of nsgtRecords) {
repos.updateDnsFields(
db,
row.id,
row.record_type,
row.name,
row.content,
row.ttl,
row.proxied,
row.priority,
SYNC_SYNCED,
"cf-stale-nsgt",
null,
);
}
const view = await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] }],
});
expect(view.domains.map((d) => d.fqdn)).toEqual(["gt.example.com"]);
expect(deleted).toContain("cf-nsgt-live");
expect(remote.some((r) => r.name.includes("nsgt"))).toBe(false);
});
});
+73
View File
@@ -376,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => {
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
@@ -412,6 +413,7 @@ describe("CNAME health mapped onto service IPs", () => {
{ ip: "10.0.0.1", weight: 1, priority: 1 },
{ ip: "10.0.0.2", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
@@ -450,6 +452,7 @@ describe("CNAME health mapped onto service IPs", () => {
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "2.59.161.102", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
@@ -478,4 +481,74 @@ describe("CNAME health mapped onto service IPs", () => {
expect(view.ip_health[0]?.status).toBe("up");
expect(view.ip_health[0]?.latency_ms).toBe(63);
});
it("getView masks stale down when health-check is disabled", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "Main TG", "main-tg");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "130.49.213.176", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"130.49.213.176",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("unknown");
expect(view.ip_health).toEqual([
expect.objectContaining({
ip: "130.49.213.176",
status: "unknown",
latency_ms: null,
last_error: null,
}),
]);
});
it("getView masks stale down when IP is disabled in pool", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "Main TG", "main-tg");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "130.49.213.176", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"130.49.213.176",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("unknown");
expect(view.ip_health[0]?.status).toBe("unknown");
});
});
+269 -50
View File
@@ -1,14 +1,24 @@
import { useCallback, useState } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboardIcon,
GlobeIcon,
ChevronRightIcon,
FolderTreeIcon,
GlobeIcon,
LayoutDashboardIcon,
PinIcon,
ServerIcon,
ShieldCheckIcon,
SettingsIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import { useSystemAttention } from '@/hooks/use-system-attention'
import { cn } from '@cfdm/ui/lib/utils'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import {
Sidebar,
SidebarContent,
@@ -18,25 +28,114 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@cfdm/ui/components/sidebar'
const infrastructureNav = [
const PINNED_KEY = 'cfdm.sidebar.pinned'
const COLLAPSED_KEY = 'cfdm.sidebar.collapsed'
type NavAttention = 'services-down' | 'certs-expiring'
type AppNavTo =
| '/'
| '/domains'
| '/services'
| '/groups'
| '/certificates'
| '/settings/appearance'
type NavItem = {
to: AppNavTo
label: string
icon: typeof GlobeIcon
exact: boolean
matchPrefix?: string
attention?: NavAttention
}
const infrastructureNav: NavItem[] = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
] as const
]
const operationsNav = [
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
] as const
const operationsNav: NavItem[] = [
{
to: '/services',
label: 'Сервисы',
icon: ServerIcon,
exact: false,
attention: 'services-down',
},
{
to: '/settings/appearance',
label: 'Настройки',
icon: SettingsIcon,
exact: false,
matchPrefix: '/settings',
},
]
const secondaryNav = [
const secondaryNav: NavItem[] = [
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
{
to: '/certificates',
label: 'Сертификаты',
icon: ShieldCheckIcon,
exact: false,
attention: 'certs-expiring',
},
]
const NAV_GROUPS = [
{ id: 'infrastructure', label: 'Инфраструктура', items: infrastructureNav },
{ id: 'operations', label: 'Операции', items: operationsNav },
{ id: 'secondary', label: 'Прочее', items: secondaryNav },
] as const
const NAV_ITEMS = NAV_GROUPS.flatMap((group) => group.items)
function readStringList(key: string) {
try {
const raw = localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter((item): item is string => typeof item === 'string')
} catch {
return []
}
}
function writeStringList(key: string, values: string[]) {
try {
localStorage.setItem(key, JSON.stringify(values))
} catch {
// хранилище недоступно — состояние остаётся в памяти
}
}
function useStringList(key: string) {
const [values, setValues] = useState<string[]>(() => readStringList(key))
const toggle = useCallback(
(id: string) => {
setValues((current) => {
const next = current.includes(id)
? current.filter((item) => item !== id)
: [...current, id]
writeStringList(key, next)
return next
})
},
[key],
)
return { values, toggle }
}
function isNavActive(
pathname: string,
to: string,
@@ -50,56 +149,148 @@ function isNavActive(
return pathname === to || pathname.startsWith(`${to}/`)
}
function NavItems({
items,
pathname,
pinned,
onTogglePin,
servicesDown,
certsExpiring,
}: {
items: readonly NavItem[]
pathname: string
pinned: readonly string[]
onTogglePin: (to: string) => void
servicesDown: number
certsExpiring: number
}) {
return (
<SidebarMenu>
{items.map((item) => {
const isPinned = pinned.includes(item.to)
const showServicesDot = item.attention === 'services-down' && servicesDown > 0
const showCertsDot = item.attention === 'certs-expiring' && certsExpiring > 0
const showDot = showServicesDot || showCertsDot
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(pathname, item.to, item.exact, item.matchPrefix)}
render={<Link to={item.to} activeOptions={{ exact: item.exact }} />}
>
<item.icon />
<span className="truncate">{item.label}</span>
{showDot ? (
<span
className={cn(
'ml-auto size-1.5 shrink-0 rounded-full group-data-[collapsible=icon]:hidden',
showServicesDot ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
{showDot ? (
<span className="sr-only">
{showServicesDot ? ', есть недоступные' : ', истекают в ближайшие 14 дней'}
</span>
) : null}
</SidebarMenuButton>
{showDot ? (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute top-1 right-1 hidden size-1.5 rounded-full group-data-[collapsible=icon]:block',
showServicesDot ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
<SidebarMenuAction
showOnHover={!isPinned}
aria-label={isPinned ? `Открепить «${item.label}»` : `Закрепить «${item.label}»`}
onClick={() => onTogglePin(item.to)}
>
<PinIcon />
</SidebarMenuAction>
</SidebarMenuItem>
)
})}
</SidebarMenu>
)
}
function NavSection({
id,
label,
items,
pathname,
collapsed,
forceOpen,
onToggleCollapsed,
pinned,
onTogglePin,
servicesDown,
certsExpiring,
}: {
id: string
label: string
items: readonly {
to: string
label: string
icon: typeof GlobeIcon
exact: boolean
matchPrefix?: string
}[]
items: readonly NavItem[]
pathname: string
collapsed: boolean
forceOpen: boolean
onToggleCollapsed: (id: string) => void
pinned: readonly string[]
onTogglePin: (to: string) => void
servicesDown: number
certsExpiring: number
}) {
const open = forceOpen || !collapsed
return (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(
pathname,
item.to,
item.exact,
item.matchPrefix,
)}
render={
<Link
to={item.to}
activeOptions={{ exact: item.exact }}
/>
}
>
<item.icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<Collapsible
open={open}
onOpenChange={(nextOpen) => {
if (forceOpen) return
if (nextOpen === open) return
onToggleCollapsed(id)
}}
className="group/nav-section"
>
<SidebarGroup>
<SidebarGroupLabel className="w-full" render={<CollapsibleTrigger />}>
<span>{label}</span>
<ChevronRightIcon
className={cn(
'ml-auto transition-transform duration-200 motion-reduce:transition-none',
open && 'rotate-90',
)}
/>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<NavItems
items={items}
pathname={pathname}
pinned={pinned}
onTogglePin={onTogglePin}
servicesDown={servicesDown}
certsExpiring={certsExpiring}
/>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { state, isMobile } = useSidebar()
const attention = useSystemAttention()
const pins = useStringList(PINNED_KEY)
const collapsed = useStringList(COLLAPSED_KEY)
const forceOpen = state === 'collapsed' && !isMobile
const pinnedItems = pins.values
.map((to) => NAV_ITEMS.find((item) => item.to === to))
.filter((item): item is NavItem => item != null)
return (
<Sidebar collapsible="icon">
@@ -107,9 +298,37 @@ export function AppSidebar() {
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
{pinnedItems.length > 0 ? (
<SidebarGroup>
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<NavItems
items={pinnedItems}
pathname={pathname}
pinned={pins.values}
onTogglePin={pins.toggle}
servicesDown={attention.servicesDown}
certsExpiring={attention.certsExpiring}
/>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{NAV_GROUPS.map((group) => (
<NavSection
key={group.id}
id={group.id}
label={group.label}
items={group.items}
pathname={pathname}
collapsed={collapsed.values.includes(group.id)}
forceOpen={forceOpen}
onToggleCollapsed={collapsed.toggle}
pinned={pins.values}
onTogglePin={pins.toggle}
servicesDown={attention.servicesDown}
certsExpiring={attention.certsExpiring}
/>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
+8 -1
View File
@@ -19,6 +19,8 @@ interface ConfirmDialogProps {
description: string
confirmLabel?: string
cancelLabel?: string
/** Кнопка подтверждения. Удаление остаётся destructive. */
confirmVariant?: 'default' | 'destructive'
onConfirm: () => void
disabled?: boolean
}
@@ -31,6 +33,7 @@ export function ConfirmDialog({
description,
confirmLabel = 'Удалить',
cancelLabel = 'Отмена',
confirmVariant = 'destructive',
onConfirm,
disabled,
}: ConfirmDialogProps) {
@@ -46,7 +49,11 @@ export function ConfirmDialog({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
<AlertDialogAction
variant={confirmVariant}
onClick={onConfirm}
disabled={disabled}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
@@ -0,0 +1,427 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { FolderTreeIcon, GlobeIcon, Link2Icon, ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { LoadingButton } from '@/components/loading-button'
import { QueryState } from '@/components/query-state'
import { SettingRow } from '@/components/setting-row'
import { StatusBadge } from '@/components/status-badge'
import {
SubdomainEditSheet,
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { TableSkeleton } from '@/components/skeletons'
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { useDomainPage } from '@/hooks/use-domain-page'
import {
type DomainDetailTab,
} from '@/components/domains/domain-detail-tabs'
import { formatDate } from '@/lib/format'
import type { ServiceBinding } from '@/lib/schemas'
import {
dnsListQueryOptions,
groupsQueryOptions,
groupKeys,
domainKeys,
updateDomain,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
const groupFormSchema = z.object({
group_id: z.string(),
})
type GroupFormValues = z.infer<typeof groupFormSchema>
interface DomainDetailActions {
openCreateSubdomain: () => void
}
function DomainChromeSkeleton() {
return (
<div className="flex flex-col gap-4">
<TableSkeleton rows={1} cols={2} />
<div className="grid gap-4 md:grid-cols-4">
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
</div>
</div>
)
}
/**
* Общая шапка, KPI и полоса вкладок карточки домена.
* Preview: https://reui.io/preview/base/stats-12
* Preview: https://reui.io/preview/base/form-7
* Preview: https://reui.io/preview/base/settings-16
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/icon-tile
*/
export function DomainDetailChrome({
domainId,
activeTab,
children,
}: {
domainId: string
activeTab: DomainDetailTab
children: (actions: DomainDetailActions) => ReactNode
}) {
const id = Number(domainId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const page = useDomainPage(id)
const groupsQuery = useQuery(groupsQueryOptions())
const dnsQuery = useQuery(dnsListQueryOptions(id))
const [groupOpen, setGroupOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [changeIp, setChangeIp] = useState<ServiceBinding | null>(null)
const domain = page.domain
const groupForm = useForm<GroupFormValues>({
resolver: zodResolver(groupFormSchema),
defaultValues: { group_id: 'none' },
})
useEffect(() => {
if (!groupOpen || !domain) return
groupForm.reset({
group_id: domain.group_id != null ? String(domain.group_id) : 'none',
})
}, [groupOpen, domain, groupForm])
const groupName = useMemo(() => {
if (!domain?.group_id) return null
return groupsQuery.data?.find((group) => group.id === domain.group_id)?.name ?? null
}, [domain, groupsQuery.data])
const groupItems = useMemo(
() => [
{ label: 'Без группы', value: 'none' },
...(groupsQuery.data ?? []).map((group) => ({
label: group.name,
value: String(group.id),
})),
],
[groupsQuery.data],
)
const updateGroupMutation = useMutation({
mutationFn: (groupId: number | null) => updateDomain(id, { group_id: groupId }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: domainKeys.detail(id) }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: groupKeys.all }),
])
setGroupOpen(false)
toast.success('Группа домена обновлена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сменить группу')
},
})
function openCreateSubdomain() {
setCreateOpen(true)
}
async function handleCreateSubdomain(values: SubdomainEditValues) {
await page.createSubdomainMutation.mutateAsync(values.name)
setCreateOpen(false)
}
function handleTabChange(next: string) {
if (next === 'dns') {
void navigate({
to: '/domains/$domainId/dns',
params: { domainId },
search: { host: undefined },
})
return
}
if (
next === 'overview' ||
next === 'availability' ||
next === 'subdomains' ||
next === 'bindings'
) {
void navigate({
to: '/domains/$domainId',
params: { domainId },
search: { tab: next },
})
}
}
const kpiCards = domain
? [
{
id: 'status',
icon: <GlobeIcon />,
label: 'Статус',
value: domain.status,
hint: <StatusBadge status={domain.status} />,
},
{
id: 'group',
icon: <FolderTreeIcon />,
label: 'Группа',
value: groupName ?? '—',
hint: groupName ? 'В группе' : 'Без группы',
},
{
id: 'bindings',
icon: <Link2Icon />,
label: 'Привязки',
value: page.bindings.length,
hint: 'Сервисы в зоне',
},
{
id: 'sync',
icon: <ServerIcon />,
label: 'Синхронизация',
value: formatDate(domain.last_synced_at),
hint: 'Последняя синхронизация',
},
]
: []
const bindings = page.bindings
return (
<QueryState
isLoading={page.isLoading}
isError={page.isError}
error={page.error}
onRetry={page.refetch}
skeleton={<DomainChromeSkeleton />}
>
{domain ? (
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Карточка домена"
actions={
<>
<Button type="button" variant="outline" onClick={() => setGroupOpen(true)}>
Сменить группу
</Button>
{bindings.length === 1 ? (
<Button
type="button"
variant="outline"
onClick={() => setChangeIp(bindings[0] ?? null)}
>
Сменить IP
</Button>
) : bindings.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
Сменить IP
</Button>
}
/>
<DropdownMenuContent align="end">
{bindings.map((binding) => (
<DropdownMenuItem
key={binding.id}
onClick={() => setChangeIp(binding)}
>
{binding.hostname}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button type="button" onClick={openCreateSubdomain}>
Добавить поддомен
</Button>
</>
}
/>
<KpiStatGrid cards={kpiCards} />
<CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
label: 'Поддомены',
count: page.subdomainRows.length,
},
{ id: 'bindings', label: 'Привязки', count: bindings.length },
{
id: 'dns',
label: 'DNS',
count: dnsQuery.data ? dnsQuery.data.length : undefined,
},
]}
value={activeTab}
onValueChange={handleTabChange}
className="flex w-full flex-col gap-4"
>
{activeTab === 'overview' ? <DomainOverviewFacts domainId={id} /> : null}
{children({ openCreateSubdomain })}
</CountedLineTabs>
<FormSheet
open={groupOpen}
onOpenChange={setGroupOpen}
title="Сменить группу"
description={`Назначить группу для ${domain.zone_name}`}
form={groupForm}
onSubmit={(values) => {
const next = values.group_id === 'none' ? null : Number(values.group_id)
updateGroupMutation.mutate(next)
}}
footer={
<LoadingButton
type="submit"
className="w-full"
isLoading={updateGroupMutation.isPending}
loadingLabel="Сохранение…"
>
Сохранить
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple label="Группа" htmlFor="domain-group-id">
<Controller
control={groupForm.control}
name="group_id"
render={({ field }) => (
<Select
items={groupItems}
value={field.value}
onValueChange={(value) => field.onChange(value ?? 'none')}
>
<SelectTrigger id="domain-group-id" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FormFieldSimple>
</FieldGroup>
</FormSheet>
<SubdomainEditSheet
mode="create"
subdomain={null}
zoneName={domain.zone_name}
services={page.services}
serviceGroupById={page.serviceGroupById}
currentServiceId="none"
open={createOpen}
isSaving={page.createSubdomainMutation.isPending}
onOpenChange={setCreateOpen}
onSubmit={handleCreateSubdomain}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.id ?? null}
serviceId={changeIp?.service_id ?? null}
currentIp={changeIp?.target_ip ?? changeIp?.target_ips?.[0]}
/>
</DetailPanel>
) : null}
</QueryState>
)
}
function DomainOverviewFacts({ domainId }: { domainId: number }) {
const page = useDomainPage(domainId)
const groupsQuery = useQuery(groupsQueryOptions())
const domain = page.domain
if (!domain) return null
const groupName = domain.group_id
? groupsQuery.data?.find((group) => group.id === domain.group_id)?.name
: null
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Зона</FrameTitle>
<FrameDescription>Параметры зоны Cloudflare</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Зона">
<span className="text-sm font-medium">{domain.zone_name}</span>
</SettingRow>
<SettingRow title="Группа">
{domain.group_id ? (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link to="/groups/$groupId" params={{ groupId: String(domain.group_id) }} />
}
>
{groupName ?? 'Открыть группу'}
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</SettingRow>
<SettingRow title="Zone ID">
<span className="font-mono text-sm break-all">{domain.cf_zone_id}</span>
</SettingRow>
<SettingRow title="Последняя синхронизация" last>
<span className="text-sm tabular-nums">{formatDate(domain.last_synced_at)}</span>
</SettingRow>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,9 @@
export const DOMAIN_SECTION_TABS = [
'overview',
'availability',
'subdomains',
'bindings',
] as const
export type DomainSectionTab = (typeof DOMAIN_SECTION_TABS)[number]
export type DomainDetailTab = DomainSectionTab | 'dns'
@@ -16,8 +16,8 @@ type HealthStatus =
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
if (status === 'healthy') return 'up'
if (status === 'unhealthy' || status === 'disabled') return 'down'
if (status === 'checking') return 'unknown'
if (status === 'unhealthy') return 'down'
if (status === 'disabled' || status === 'checking') return 'unknown'
return status
}
@@ -0,0 +1,33 @@
import { CircleAlertIcon } from 'lucide-react'
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@cfdm/ui/components/button'
interface IncidentAlertProps {
title: string
description?: string
onShow: () => void
}
/**
* Баннер самого тяжёлого инцидента.
* Preview: https://reui.io/docs/components/base/alert
*/
export function IncidentAlert({ title, description, onShow }: IncidentAlertProps) {
return (
<Alert variant="destructive">
<CircleAlertIcon aria-hidden />
<AlertTitle>{title}</AlertTitle>
{description ? <AlertDescription>{description}</AlertDescription> : null}
<AlertAction>
<Button type="button" variant="outline" size="xs" onClick={onShow}>
Показать
</Button>
</AlertAction>
</Alert>
)
}
@@ -2,8 +2,10 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { TriangleAlertIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormFieldSimple } from '@/components/form-field'
@@ -57,11 +59,23 @@ export function VpsTrackerIntegrationCard({
const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary'
const hasUnsaved = form.formState.isDirty
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
{hasUnsaved ? (
<Alert variant="warning" role="status">
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>Есть несохранённые изменения</AlertTitle>
<AlertDescription>
URL, токен или синхронизация отличаются от последних сохранённых
значений.
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={syncBadgeVariant}>
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
+30 -21
View File
@@ -1,34 +1,43 @@
import type { CSSProperties, ReactNode } from 'react'
import { useEffect, type CSSProperties, type ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { AppSidebar } from '@/components/app-sidebar'
import { CommandPalette } from '@/components/layout/command-palette'
import { SiteHeader } from '@/components/layout/site-header'
import { SearchMenu } from '@/components/layout/search-menu'
import { SystemAttentionProvider } from '@/components/layout/system-attention-provider'
import { bindLiveRefresh } from '@/lib/live-refresh'
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
export function AppShell({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
useEffect(() => bindLiveRefresh(queryClient), [queryClient])
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<SearchMenu hotkeyOnly />
</SidebarProvider>
<SystemAttentionProvider>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</SystemAttentionProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
BlocksIcon,
FolderTreeIcon,
GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon,
PaletteIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
} from 'lucide-react'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@cfdm/ui/components/command'
const SECTION_ITEMS = [
{
to: '/',
label: 'Панель',
keywords: 'dashboard панель управление обзор',
icon: LayoutDashboardIcon,
},
{
to: '/domains',
label: 'Домены',
keywords: 'domains зоны cloudflare',
icon: GlobeIcon,
},
{
to: '/services',
label: 'Сервисы',
keywords: 'services сервисы fqdn',
icon: ServerIcon,
},
{
to: '/groups',
label: 'Группы',
keywords: 'groups группы доменов',
icon: FolderTreeIcon,
},
{
to: '/certificates',
label: 'Сертификаты',
keywords: 'certificates ssl tls',
icon: ShieldCheckIcon,
},
{
to: '/settings/appearance',
label: 'Настройки',
keywords: 'settings настройки',
icon: SettingsIcon,
},
] as const
const ACTION_ITEMS = [
{
to: '/settings/health',
label: 'Health-check',
keywords: 'health проверка пороги cron',
icon: HeartPulseIcon,
},
{
to: '/settings/integrations',
label: 'Интеграции',
keywords: 'integrations app switcher портал',
icon: BlocksIcon,
},
{
to: '/settings/appearance',
label: 'Внешний вид',
keywords: 'appearance тема быстрые действия',
icon: PaletteIcon,
},
] as const
type PaletteTarget =
| (typeof SECTION_ITEMS)[number]['to']
| (typeof ACTION_ITEMS)[number]['to']
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
export function CommandPalette() {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.repeat) return
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
event.preventDefault()
setOpen((current) => !current)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
function go(to: PaletteTarget) {
setOpen(false)
void navigate({ to })
}
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Палитра команд"
description="Разделы и действия приложения"
className="sm:max-w-md"
>
<Command key={open ? 'open' : 'closed'}>
<CommandInput autoFocus placeholder="Перейти к разделу или действию…" />
<CommandList>
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Разделы">
{SECTION_ITEMS.map((item) => (
<CommandItem
key={item.to}
value={`${item.label} ${item.keywords}`}
onSelect={() => go(item.to)}
>
<item.icon />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Действия">
{ACTION_ITEMS.map((item) => (
<CommandItem
key={item.label}
value={`${item.label} ${item.keywords}`}
onSelect={() => go(item.to)}
>
<item.icon />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Switch } from '@cfdm/ui/components/switch'
import { formatRelative } from '@/lib/format'
import { isLiveRefresh, setLiveRefresh } from '@/lib/live-refresh'
function useLatestDataUpdatedAt() {
const queryClient = useQueryClient()
const [updatedAt, setUpdatedAt] = useState(0)
useEffect(() => {
const cache = queryClient.getQueryCache()
let queued = false
let active = true
const read = () => {
let latest = 0
for (const query of cache.getAll()) {
if (query.getObserversCount() === 0) continue
if (query.state.dataUpdatedAt > latest) latest = query.state.dataUpdatedAt
}
setUpdatedAt((prev) => (prev === latest ? prev : latest))
}
// Cache notifies synchronously while another route renders (observerAdded).
const schedule = () => {
if (queued) return
queued = true
queueMicrotask(() => {
queued = false
if (active) read()
})
}
read()
const unsubscribe = cache.subscribe(schedule)
return () => {
active = false
unsubscribe()
}
}, [queryClient])
return updatedAt
}
function formatUpdatedAt(updatedAt: number) {
if (updatedAt === 0) return '—'
return formatRelative(new Date(updatedAt).toISOString())
}
/** Компактный статус свежести слева от AppsMenu. */
export function DataFreshness() {
const queryClient = useQueryClient()
const updatedAt = useLatestDataUpdatedAt()
const [live, setLive] = useState(() => isLiveRefresh())
const [label, setLabel] = useState('—')
useEffect(() => {
const read = () => setLabel(formatUpdatedAt(updatedAt))
read()
if (updatedAt === 0) return
const timer = window.setInterval(read, 15_000)
return () => window.clearInterval(timer)
}, [updatedAt])
function handleCheckedChange(checked: boolean) {
const next = Boolean(checked)
setLiveRefresh(queryClient, next)
setLive(next)
}
return (
<div className="flex items-center gap-2">
<p className="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex">
<span className="tabular-nums">
Обновлено{' '}
<time dateTime={updatedAt ? new Date(updatedAt).toISOString() : undefined}>
{label}
</time>
</span>
<span aria-hidden="true">·</span>
<span>{live ? 'Live' : 'Пауза'}</span>
</p>
<Switch
size="sm"
checked={live}
onCheckedChange={handleCheckedChange}
aria-label={live ? 'Выключить автообновление' : 'Включить автообновление'}
/>
</div>
)
}
@@ -1,189 +0,0 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import {
FolderTreeIcon,
GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@cfdm/ui/components/dialog'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Панель управления',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/domains',
label: 'Домены',
keywords: ['domains', 'зоны', 'cloudflare'],
icon: GlobeIcon,
},
{
to: '/groups',
label: 'Группы доменов',
keywords: ['groups', 'группы', 'канбан'],
icon: FolderTreeIcon,
},
{
to: '/services',
label: 'Сервисы',
keywords: ['services', 'сервисы', 'fqdn'],
icon: ServerIcon,
},
{
to: '/certificates',
label: 'Сертификаты',
keywords: ['certificates', 'ssl', 'tls'],
icon: ShieldCheckIcon,
},
{
to: '/settings/health',
label: 'Health-check',
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
icon: HeartPulseIcon,
},
{
to: '/settings/integrations',
label: 'Настройки',
keywords: ['settings', 'интеграции', 'app switcher'],
icon: SettingsIcon,
},
] as const
/** Command-K search — hotkey dialog (no header chrome trigger). Etalon: EvoBGP CommandPalette hotkeyOnly. */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
if (!open) setQuery('')
}, [open])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return NAV_ITEMS
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.keywords.some((k) => k.includes(q)),
)
}, [query])
function goTo(to: string) {
setOpen(false)
void navigate({ to })
}
return (
<>
{hotkeyOnly ? null : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Поиск"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<SearchIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Поиск</DialogTitle>
<DialogDescription>
Переход к разделам приложения
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
<div className="relative flex items-center gap-3 border-b px-4 py-2">
<SearchIcon
aria-hidden="true"
className="pointer-events-none size-4 opacity-60 select-none"
/>
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Перейти к разделу…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) {
e.preventDefault()
goTo(filtered[0].to)
}
}}
/>
</div>
<ItemGroup className="max-h-72 overflow-y-auto p-2">
{filtered.length === 0 ? (
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
Ничего не найдено
</p>
) : (
filtered.map((item) => (
<Item
key={item.to}
size="sm"
variant="muted"
className="cursor-pointer border-0"
render={<Link to={item.to} onClick={() => setOpen(false)} />}
>
<ItemMedia variant="icon">
<item.icon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>{item.label}</ItemTitle>
</ItemContent>
</Item>
))
)}
</ItemGroup>
</DialogContent>
</Dialog>
</>
)
}
@@ -9,8 +9,9 @@ import {
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { DataFreshness } from '@/components/layout/data-freshness'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
import { getBreadcrumbs } from '@/lib/breadcrumbs'
@@ -32,7 +33,7 @@ function useDynamicBreadcrumbLabels() {
}, [matches])
}
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
@@ -71,6 +72,7 @@ export function SiteHeader() {
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<DataFreshness />
<AppsMenu />
<SystemMonitorPopover />
</div>
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import {
SystemAttentionContext,
useSystemAttentionData,
} from '@/hooks/use-system-attention'
export function SystemAttentionProvider({ children }: { children: ReactNode }) {
const attention = useSystemAttentionData()
return (
<SystemAttentionContext.Provider value={attention}>
{children}
</SystemAttentionContext.Provider>
)
}
@@ -1,5 +1,5 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
Activity,
HeartPulse,
@@ -9,17 +9,14 @@ import {
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { useSystemAttention } from '@/hooks/use-system-attention'
import type { SystemIncident } from '@/lib/system-attention'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
import { Progress } from '@cfdm/ui/components/progress'
import {
certSummaryQueryOptions,
domainsListQueryOptions,
notificationLogQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
const VISIBLE_INCIDENTS = 8
type MonitorMetric = {
id: string
@@ -32,12 +29,6 @@ type MonitorMetric = {
alert: boolean
}
type ReadyResponse = {
status: string
database?: boolean
cloudflare?: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
@@ -81,110 +72,95 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
)
}
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
if (!summary) return 0
return summary
.filter(([status]) => statuses.includes(status))
.reduce((sum, [, count]) => sum + count, 0)
function IncidentLink({ incident }: { incident: SystemIncident }) {
const className =
'hover:bg-muted flex items-center gap-2 rounded-md px-1.5 py-1 text-xs'
const body = (
<>
<span
aria-hidden="true"
className={cn(
'size-1.5 shrink-0 rounded-full',
incident.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
)}
/>
<span className="min-w-0 flex-1 truncate">{incident.title}</span>
<span className="text-muted-foreground shrink-0">{incident.detail}</span>
</>
)
if (incident.href.to === '/certificates') {
return (
<Link to="/certificates" className={className}>
{body}
</Link>
)
}
return (
<Link
to="/services/$serviceId"
params={{ serviceId: incident.href.serviceId }}
className={className}
>
{body}
</Link>
)
}
/** Live system monitor popover (app-shell pattern, CFDM API data). */
export function SystemMonitorPopover() {
const readyQ = useQuery({
queryKey: ['ready'],
queryFn: async (): Promise<ReadyResponse> => {
const res = await fetch('/ready')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<ReadyResponse>
},
refetchInterval: 30_000,
})
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 })
const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 })
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 })
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 })
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError
let healthDown = 0
let healthDegraded = 0
for (const group of servicesQ.data?.groups ?? []) {
for (const service of group.services) {
if (service.health_status === 'down') healthDown += 1
if (service.health_status === 'degraded') healthDegraded += 1
}
}
for (const service of servicesQ.data?.ungrouped ?? []) {
if (service.health_status === 'down') healthDown += 1
if (service.health_status === 'degraded') healthDegraded += 1
}
const healthAlert = healthDown > 0
const healthWarn = healthDegraded > 0
const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error'])
const failedJobs = (notifyQ.data ?? []).filter((n) =>
/fail|error/i.test(`${n.kind} ${n.title} ${n.message}`),
).length
const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length
const attentionCount = healthDown + healthDegraded
const attention = useSystemAttention()
const visibleIncidents = attention.incidents.slice(0, VISIBLE_INCIDENTS)
const hiddenIncidents = attention.incidents.length - visibleIncidents.length
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'ready',
label: 'Ready',
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
value: attention.readyOk ? 'OK' : attention.readyDegraded ? '!' : '—',
unit: '',
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
percent: attention.readyOk ? 100 : attention.readyDegraded ? 40 : 0,
icon: <HeartPulse aria-hidden />,
tone: readyOk ? 'success' : 'destructive',
alert: !readyOk,
tone: attention.readyOk ? 'success' : 'destructive',
alert: !attention.readyOk,
},
{
id: 'health',
label: 'Health-check',
value: String(healthDown + healthDegraded),
value: String(attention.servicesDown + attention.servicesDegraded),
unit: 'шт.',
percent: Math.min(100, (healthDown + healthDegraded) * 20),
percent: Math.min(100, (attention.servicesDown + attention.servicesDegraded) * 20),
icon: <Server aria-hidden />,
tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success',
alert: healthAlert || healthWarn,
tone: attention.servicesDown > 0 ? 'destructive' : attention.servicesDegraded > 0 ? 'warning' : 'success',
alert: attention.servicesDown > 0 || attention.servicesDegraded > 0,
},
{
id: 'certs',
label: 'Сертификаты',
value: String(certWarnings),
value: String(attention.certWarnings),
unit: 'шт.',
percent: Math.min(100, certWarnings * 20),
percent: Math.min(100, attention.certWarnings * 20),
icon: <ShieldCheck aria-hidden />,
tone: certWarnings > 0 ? 'warning' : 'success',
alert: certWarnings > 0,
tone: attention.certWarnings > 0 ? 'warning' : 'success',
alert: attention.certWarnings > 0,
},
{
id: 'jobs',
label: 'Ошибки',
value: String(failedJobs),
value: String(attention.failedJobs),
unit: 'шт.',
percent: Math.min(100, failedJobs * 15),
percent: Math.min(100, attention.failedJobs * 15),
icon: <ListChecks aria-hidden />,
tone: failedJobs > 0 ? 'warning' : 'success',
alert: failedJobs > 0,
tone: attention.failedJobs > 0 ? 'warning' : 'success',
alert: attention.failedJobs > 0,
},
],
[
certWarnings,
failedJobs,
healthAlert,
healthDegraded,
healthDown,
healthWarn,
readyDegraded,
readyOk,
],
[attention],
)
const spiking = metrics.some((m) => m.alert)
const spiking = metrics.some((metric) => metric.alert)
return (
<Popover>
@@ -242,12 +218,29 @@ export function SystemMonitorPopover() {
</div>
))}
</div>
<div className="border-border flex flex-col gap-1 border-t px-2 py-2">
<span className="text-muted-foreground px-1.5 text-xs font-medium">Инциденты</span>
{visibleIncidents.length === 0 ? (
<p className="text-muted-foreground px-1.5 py-1 text-xs">Нет инцидентов</p>
) : (
<div className="flex flex-col gap-0.5">
{visibleIncidents.map((incident) => (
<IncidentLink key={incident.id} incident={incident} />
))}
</div>
)}
{hiddenIncidents > 0 ? (
<p className="text-muted-foreground px-1.5 text-xs">Ещё {hiddenIncidents}</p>
) : null}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
Без группы:{' '}
<span className="text-foreground font-medium tabular-nums">{ungroupedCount}</span>
<span className="text-foreground font-medium tabular-nums">{attention.ungroupedCount}</span>
{' · '}
Внимание:{' '}
<span className="text-foreground font-medium tabular-nums">{attentionCount}</span>
<span className="text-foreground font-medium tabular-nums">
{attention.servicesDown + attention.servicesDegraded}
</span>
</div>
</PopoverContent>
</Popover>
@@ -1,6 +1,11 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { CircleAlertIcon, type LucideIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Frame,
@@ -9,12 +14,14 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils'
/**
* Sibling Frame columns for dashboard attention queue.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
* Each column is its own Frame; children must not wrap another Frame.
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/dashboard-1
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
*/
export interface AttentionQueueColumn {
id: string
@@ -26,6 +33,9 @@ export interface AttentionQueueColumn {
emptyTitle: string
emptyDescription: string
emptyAction?: ReactNode
isError?: boolean
errorMessage?: string
onRetry?: () => void
children: ReactNode
}
@@ -46,7 +56,8 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
const isError = column.isError === true
const isEmpty = !isError && column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
@@ -71,8 +82,30 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<FramePanel className={cn('min-w-0', !isEmpty && !isError && 'p-0')}>
{isError ? (
<div className="flex min-h-28 items-center py-4">
<Alert variant="destructive" className="w-full">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col items-start gap-2">
<span>
{column.errorMessage ?? 'Не удалось загрузить данные'}
</span>
{column.onRetry ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={column.onRetry}
>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
</div>
) : isEmpty ? (
<div className="flex min-h-28 items-center justify-center py-4">
<EmptyState
icon={Icon}
@@ -0,0 +1,314 @@
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
import type { Certificate, DomainListItem, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
/**
* Compact queue tables for the dashboard. Parent Frame owns the shell —
* this grid does not render another Frame.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame
*/
function formatWhen(value: string | null | undefined) {
if (!value) return '—'
return formatRelative(sqliteUtcToIso(value) ?? value)
}
function nodeLabel(service: ServiceView) {
const health = service.ip_health
const total = Math.max(service.ips.length, health.length)
if (total === 0) return '—'
if (health.length === 0) return String(total)
const healthy = health.filter((row) => row.status === 'up').length
return `${healthy}/${total}`
}
function latestCheck(service: ServiceView) {
let checkedAt: string | null = null
for (const row of service.ip_health) {
if (!row.last_checked_at) continue
if (!checkedAt || row.last_checked_at > checkedAt) checkedAt = row.last_checked_at
}
return checkedAt
}
function QueueGrid<T extends object>({
data,
columns,
getRowId,
}: {
data: T[]
columns: ColumnDef<T, unknown>[]
getRowId: (row: T) => string
}) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
getRowId,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<DataGrid
table={table}
recordCount={data.length}
tableLayout={{
dense: true,
headerBorder: true,
rowBorder: true,
width: 'auto',
}}
>
<DataGridScrollArea orientation="horizontal">
<DataGridTable />
</DataGridScrollArea>
</DataGrid>
)
}
interface HealthProblemGridProps {
services: ServiceView[]
}
export function HealthProblemGrid({ services }: HealthProblemGridProps) {
const columns = useMemo<ColumnDef<ServiceView, unknown>[]>(
() => [
{
id: 'name',
accessorKey: 'name',
size: 140,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервис" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link to="/services" search={{ serviceId: row.original.id }} />
}
>
{row.original.name}
</Button>
),
},
{
id: 'status',
accessorKey: 'health_status',
size: 96,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => (
<HealthCheckBadge status={row.original.health_status} size="xs" />
),
},
{
id: 'latency',
accessorKey: 'health_latency_ms',
size: 88,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Задержка" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.health_latency_ms != null
? `${row.original.health_latency_ms} мс`
: '—'}
</span>
),
},
{
id: 'nodes',
accessorFn: (row) => Math.max(row.ips.length, row.ip_health.length),
size: 72,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Ноды" />
),
cell: ({ row }) => (
<span className="tabular-nums">{nodeLabel(row.original)}</span>
),
},
{
id: 'checked',
accessorFn: (row) => latestCheck(row) ?? '',
size: 104,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Когда" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(latestCheck(row.original))}
</span>
),
},
],
[],
)
return (
<QueueGrid
data={services}
columns={columns}
getRowId={(row) => String(row.id)}
/>
)
}
interface ExpiringCertGridProps {
certs: Certificate[]
}
export function ExpiringCertGrid({ certs }: ExpiringCertGridProps) {
const columns = useMemo<ColumnDef<Certificate, unknown>[]>(
() => [
{
id: 'hostname',
accessorKey: 'hostname',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Хост" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={<Link to="/certificates" />}
>
{row.original.hostname}
</Button>
),
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'service',
accessorFn: (row) => row.service_name ?? '',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервис" />
),
cell: ({ row }) => (
<span className="truncate">
{row.original.service_name?.trim() || '—'}
</span>
),
},
{
id: 'expires',
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Истекает" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(row.original.expires_at)}
</span>
),
},
],
[],
)
return (
<QueueGrid data={certs} columns={columns} getRowId={(row) => String(row.id)} />
)
}
interface UngroupedDomainGridProps {
domains: DomainListItem[]
}
export function UngroupedDomainGrid({ domains }: UngroupedDomainGridProps) {
const columns = useMemo<ColumnDef<DomainListItem, unknown>[]>(
() => [
{
id: 'zone_name',
accessorKey: 'zone_name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Домен" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(row.original.id) }}
/>
}
>
{row.original.zone_name}
</Button>
),
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'services',
accessorKey: 'service_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.service_count}</span>
),
},
{
id: 'synced',
accessorKey: 'last_synced_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Синхронизация" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(row.original.last_synced_at)}
</span>
),
},
],
[],
)
return (
<QueueGrid
data={domains}
columns={columns}
getRowId={(row) => String(row.id)}
/>
)
}
@@ -17,6 +17,11 @@ export {
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
export {
ExpiringCertGrid,
HealthProblemGrid,
UngroupedDomainGrid,
} from './dashboard-queue-grid'
export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
@@ -9,7 +9,12 @@ import {
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react'
import {
CircleAlertIcon,
FilterIcon,
FunnelXIcon,
type LucideIcon,
} from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
@@ -67,7 +72,27 @@ export interface ResourcePageProps<T extends object> {
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
emptyState?: {
title: string
description?: string
action?: ReactNode
icon?: LucideIcon
}
/** Пустой результат фильтров или вкладки. Preview: https://reui.io/preview/base/empty-state-12 */
filteredEmptyState?: {
title: string
description?: string
resetLabel?: string
icon?: LucideIcon
}
onFilteredEmptyReset?: () => void
/** Ошибка загрузки через EmptyState вместо общего Alert. */
errorState?: {
title: string
description?: string
action?: ReactNode
icon?: LucideIcon
}
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
@@ -119,6 +144,9 @@ export function ResourcePage<T extends object>({
onRetry,
primaryAction,
emptyState,
filteredEmptyState,
onFilteredEmptyReset,
errorState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
@@ -224,6 +252,25 @@ export function ResourcePage<T extends object>({
}
if (isError) {
if (errorState) {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
icon={errorState.icon ?? CircleAlertIcon}
title={errorState.title}
description={
errorState.description ??
error?.message ??
'Не удалось загрузить данные'
}
action={errorState.action}
/>
</FramePanel>
</Frame>
)
}
return (
<Alert variant="destructive">
<CircleAlertIcon />
@@ -262,6 +309,7 @@ export function ResourcePage<T extends object>({
) : null}
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
icon={emptyState.icon}
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
@@ -271,7 +319,30 @@ export function ResourcePage<T extends object>({
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
const emptyMessage = filteredEmptyState ? (
<EmptyState
className="text-foreground"
icon={filteredEmptyState.icon}
title={filteredEmptyState.title}
description={filteredEmptyState.description}
action={
filteredEmptyState.resetLabel ? (
<Button
type="button"
variant="outline"
onClick={() => {
handleClear()
onFilteredEmptyReset?.()
}}
>
{filteredEmptyState.resetLabel}
</Button>
) : null
}
/>
) : (
'Нет записей по выбранным фильтрам.'
)
return (
<div className="w-full">
@@ -0,0 +1,115 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import {
ServiceDetailGrid,
type ServiceFqdnRow,
} from '@/components/services/service-detail-grid'
import { useServiceDetailActions } from '@/components/services/service-detail-context'
import { api } from '@/lib/api-client'
import type { ServiceView } from '@/lib/schemas'
import {
deleteServiceNode,
invalidateServiceDetail,
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
interface ServiceNodeRow {
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
}
export function ServiceAssetSection({
serviceId,
section,
}: {
serviceId: string
section: 'ip' | 'fqdn' | 'nodes' | 'ssl'
}) {
const id = Number(serviceId)
const queryClient = useQueryClient()
const actions = useServiceDetailActions()
const viewQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const [togglingIp, setTogglingIp] = useState<string | null>(null)
const service = viewQuery.data
const probes = logQuery.data?.items ?? []
const nodes = Array.isArray(nodesQuery.data)
? (nodesQuery.data as ServiceNodeRow[])
: []
const toggleIpMutation = useMutation({
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
onSuccess: async (_data, { enabled }) => {
await invalidateServiceDetail(queryClient, id)
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
},
onSettled: () => setTogglingIp(null),
})
const deleteNodeMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await invalidateServiceDetail(queryClient, id)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить ноду')
},
})
return (
<QueryState
isLoading={viewQuery.isLoading}
isError={viewQuery.isError}
error={viewQuery.error}
onRetry={() => {
void viewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : (
<ServiceDetailGrid
service={service}
nodes={nodes}
probes={probes}
section={section}
togglingIp={togglingIp}
isLoading={nodesQuery.isLoading}
onToggleIp={(ip, enabled) => {
setTogglingIp(ip)
toggleIpMutation.mutate({ ip, enabled })
}}
onChangeIp={(row: ServiceFqdnRow) => actions.openChangeIp(row)}
onChangeDomain={() => actions.openChangeDomain()}
onAddNode={actions.openAddNode}
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
/>
)}
</QueryState>
)
}
@@ -0,0 +1,29 @@
import { createContext, createElement, useContext, type ReactNode } from 'react'
import type { ServiceFqdnRow } from '@/components/services/service-detail-grid'
export interface ServiceDetailActions {
openAddNode: () => void
openChangeIp: (row: ServiceFqdnRow) => void
openChangeDomain: () => void
}
const ServiceDetailActionsContext = createContext<ServiceDetailActions | null>(null)
export function ServiceDetailActionsProvider({
value,
children,
}: {
value: ServiceDetailActions
children: ReactNode
}) {
return createElement(ServiceDetailActionsContext.Provider, { value }, children)
}
export function useServiceDetailActions() {
const value = useContext(ServiceDetailActionsContext)
if (!value) {
throw new Error('Действия сервиса доступны только внутри карточки сервиса')
}
return value
}
@@ -228,6 +228,8 @@ interface ServiceDetailGridProps {
onAddNode: () => void
onDeleteNode: (nodeId: number) => void
isLoading?: boolean
/** Фиксирует одну секцию без внутренних вкладок IP / FQDN / Ноды / SSL. */
section?: 'ip' | 'fqdn' | 'nodes' | 'ssl'
}
export function ServiceDetailGrid({
@@ -241,8 +243,10 @@ export function ServiceDetailGrid({
onAddNode,
onDeleteNode,
isLoading = false,
section,
}: ServiceDetailGridProps) {
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
const [tabState, setTab] = useState<(typeof TABS)[number]['id']>(section ?? 'ip')
const tab = section ?? tabState
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
createFilter('ip', 'contains', ['']),
createFilter('status', 'is', ['']),
@@ -671,17 +675,34 @@ export function ServiceDetailGrid({
[patchCertMonitoring],
)
const sharedTabs = {
tabs,
activeTab: tab,
onTabChange: (id: string) => setTab(id as typeof tab),
}
const heading =
section === 'nodes'
? { title: 'Ноды', description: 'Адреса пула и состояние проверок' }
: section === 'fqdn'
? { title: 'Поддомены', description: 'FQDN, привязанные к сервису' }
: section === 'ip'
? {
title: 'Маршрутизация',
description: 'Веса, приоритеты и включённые адреса',
}
: {
title: 'Активы сервиса',
description: 'IP, FQDN и ноды этого сервиса',
}
const sharedTabs = section
? {}
: {
tabs,
activeTab: tab,
onTabChange: (id: string) => setTab(id as typeof tab),
}
if (tab === 'fqdn') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={fqdnFilterFields}
filters={fqdnFilters}
@@ -711,8 +732,8 @@ export function ServiceDetailGrid({
if (tab === 'nodes') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={nodeFilterFields}
filters={nodeFilters}
@@ -733,10 +754,25 @@ export function ServiceDetailGrid({
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
section ? undefined : (
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
)
}
emptyState={
section === 'nodes'
? {
title: 'Нет нод',
description: 'Добавьте ноду в пул сервиса.',
action: (
<Button type="button" variant="outline" onClick={onAddNode}>
Добавить ноду
</Button>
),
}
: undefined
}
/>
)
@@ -745,8 +781,12 @@ export function ServiceDetailGrid({
if (tab === 'ssl') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN, ноды и SSL этого сервиса"
title={section === 'ssl' ? 'SSL' : 'Активы сервиса'}
description={
section === 'ssl'
? 'Сертификаты привязанных FQDN'
: 'IP, FQDN, ноды и SSL этого сервиса'
}
{...sharedTabs}
filterFields={sslFilterFields}
filters={sslFilters}
@@ -790,8 +830,8 @@ export function ServiceDetailGrid({
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={ipFilterFields}
filters={ipFilters}
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
healthCheckEnabled?: boolean
ipEnabled?: Record<string, boolean>
togglingIp?: string | null
ipToggleDisabled?: boolean
@@ -151,6 +152,7 @@ interface ServiceIpListProps {
export function ServiceIpList({
ips,
ipHealth = [],
healthCheckEnabled = true,
ipEnabled = {},
togglingIp = null,
ipToggleDisabled = false,
@@ -184,6 +186,10 @@ export function ServiceIpList({
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
const monitored = healthCheckEnabled && enabled
const badgeStatus = monitored
? (health?.status ?? 'unknown')
: 'disabled'
return (
<Item
key={ip}
@@ -192,7 +198,7 @@ export function ServiceIpList({
>
<ItemMedia>
<HealthCheckBadge
status={health?.status ?? 'unknown'}
status={badgeStatus}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
@@ -0,0 +1,134 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, UnplugIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { QueryState } from '@/components/query-state'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { ServiceHealthMonitor } from '@/components/reui-kit'
import {
enabledHealthProviders,
providerHealthStatuses,
type HealthLogProbe,
} from '@/lib/health-log'
import type { FailoverHistoryItem } from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import {
serviceFailoverLogQueryOptions,
serviceHealthLogQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
function failoverHistory(items: readonly FailoverLogEntry[]): FailoverHistoryItem[] {
return items.map((item) => ({
id: `dns:${item.id}`,
ip: item.ip,
fqdn: item.fqdn,
action: item.action,
created_at: item.created_at,
copy:
item.action === 'removed'
? `${item.ip} убрана с ${item.fqdn}`
: `${item.ip} добавлена на ${item.fqdn}`,
source: 'dns',
}))
}
function hasLatencySeries(items: readonly HealthLogProbe[]) {
return items.some((item) => item.latency_ms != null && item.checked_at.length > 0)
}
/**
* Health карточки сервиса: график задержки и timeline failover
* только по строкам журналов, без синтетических точек.
*
* Preview: https://reui.io/preview/base/chart-17
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/frame
*/
export function ServiceHealthSection({ serviceId }: { serviceId: string }) {
const id = Number(serviceId)
const viewQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const failoverQuery = useQuery(serviceFailoverLogQueryOptions(id))
const probes = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items])
const failoverItems = useMemo(
() => failoverQuery.data?.items ?? [],
[failoverQuery.data?.items],
)
const history = useMemo(() => failoverHistory(failoverItems), [failoverItems])
const showLatency = hasLatencySeries(probes)
const enabledProviders = useMemo(
() => enabledHealthProviders(viewQuery.data?.domains ?? []),
[viewQuery.data?.domains],
)
const providerStatuses = useMemo(
() => providerHealthStatuses(probes, enabledProviders),
[probes, enabledProviders],
)
const isLoading = logQuery.isLoading || failoverQuery.isLoading
const isError = logQuery.isError || failoverQuery.isError
const error = logQuery.error ?? failoverQuery.error
return (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void logQuery.refetch()
void failoverQuery.refetch()
}}
>
<div className="flex flex-col gap-4">
{showLatency ? (
<ServiceHealthMonitor
items={probes}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isFetching}
/>
) : (
<EmptyState
icon={ActivityIcon}
title="Нет ряда задержки"
description="Журнал health-log не содержит замеров latency. График появится после проверок с задержкой."
stackedIcon={false}
centered={false}
/>
)}
{history.length > 0 ? (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>События журнала смены адресов</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={[]} history={history} />
</FramePanel>
</Frame>
) : (
<EmptyState
icon={UnplugIcon}
title="Нет событий failover"
description="Журнал failover пуст — timeline строится только по его записям."
stackedIcon={false}
centered={false}
/>
)}
</div>
</QueryState>
)
}
@@ -0,0 +1,67 @@
import { useQuery } from '@tanstack/react-query'
import { SettingRow } from '@/components/setting-row'
import { ServiceAssetSection } from '@/components/services/service-asset-section'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import type { ServiceView } from '@/lib/schemas'
import { serviceOverviewQueryOptions, serviceViewQueryOptions } from '@/queries'
const LB_LABEL: Record<ServiceView['lb_mode'], string> = {
round_robin: 'Round robin',
failover: 'Failover',
weighted: 'По весам',
}
interface OverviewRouting {
routing_strategy?: string
active_addresses?: string[]
}
/**
* Preview: https://reui.io/preview/base/settings-16
* Docs: https://reui.io/docs/components/base/frame
*/
export function ServiceRoutingSection({ serviceId }: { serviceId: string }) {
const id = Number(serviceId)
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const service = viewQuery.data
const overview = overviewQuery.data as OverviewRouting | undefined
const active = overview?.active_addresses ?? service?.active_ips ?? []
return (
<div className="flex flex-col gap-4">
{service ? (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Маршрутизация</FrameTitle>
<FrameDescription>Режим балансировки и активные адреса</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Режим">
<Badge variant="outline" size="sm">
{LB_LABEL[service.lb_mode]}
</Badge>
</SettingRow>
<SettingRow title="Стратегия">
<span className="text-sm">
{overview?.routing_strategy ?? LB_LABEL[service.lb_mode]}
</span>
</SettingRow>
<SettingRow title="Активные адреса" last>
<span className="text-sm font-mono">{active.join(', ') || '—'}</span>
</SettingRow>
</FramePanel>
</Frame>
) : null}
<ServiceAssetSection serviceId={serviceId} section="ip" />
</div>
)
}
@@ -273,6 +273,10 @@ export function ServiceUnitCard({
alignWithMenu
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
healthCheckEnabled={
service.enabled &&
(service.domains ?? []).some((domain) => domain.health_check_enabled)
}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
@@ -249,11 +249,11 @@ export function ServicesGroupedCatalog({
return (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
title="Нет сервисов"
description={
domainLabel
? `Нет сервисов с привязками к ${domainLabel}`
: 'Сначала создайте группу, затем добавьте сервисы.'
: 'Создайте сервис и добавьте поддомен с нодами.'
}
action={emptyAction}
/>
@@ -0,0 +1,68 @@
import { createContext, useContext, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { deriveSystemAttention, type SystemAttention } from '@/lib/system-attention'
import {
certificatesQueryOptions,
certSummaryQueryOptions,
domainsListQueryOptions,
notificationLogQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
const POLL_MS = 30_000
type ReadyResponse = {
status: string
}
export const SystemAttentionContext = createContext<SystemAttention | null>(null)
export function useSystemAttentionData(): SystemAttention {
const readyQ = useQuery({
queryKey: ['ready'],
queryFn: async (): Promise<ReadyResponse> => {
const res = await fetch('/ready')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<ReadyResponse>
},
refetchInterval: POLL_MS,
})
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: POLL_MS })
const certSummaryQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: POLL_MS })
const certificatesQ = useQuery({ ...certificatesQueryOptions(), refetchInterval: POLL_MS })
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: POLL_MS })
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: POLL_MS })
return useMemo(
() =>
deriveSystemAttention({
services: servicesQ.data,
certificates: certificatesQ.data,
certSummary: certSummaryQ.data,
notifications: notifyQ.data,
domains: domainsQ.data,
readyStatus: readyQ.data?.status,
readyIsSuccess: readyQ.isSuccess,
readyIsError: readyQ.isError,
}),
[
certificatesQ.data,
certSummaryQ.data,
domainsQ.data,
notifyQ.data,
readyQ.data?.status,
readyQ.isError,
readyQ.isSuccess,
servicesQ.data,
],
)
}
export function useSystemAttention() {
const attention = useContext(SystemAttentionContext)
if (!attention) {
throw new Error('useSystemAttention должен вызываться внутри SystemAttentionProvider')
}
return attention
}
+25
View File
@@ -18,6 +18,31 @@ describe('getBreadcrumbs', () => {
])
})
it('keeps the service name on nested service tabs', () => {
expect(
getBreadcrumbs('/services/9/health', { '/services/9': '[IHOR] Москва' }),
).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: '[IHOR] Москва', href: '/services/9' },
{ label: 'Health', href: '/services/9/health' },
])
expect(getBreadcrumbs('/services/9/routing')).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: 'Сервис', href: '/services/9' },
{ label: 'Маршрутизация', href: '/services/9/routing' },
])
expect(getBreadcrumbs('/services/9/nodes')).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: 'Сервис', href: '/services/9' },
{ label: 'Ноды', href: '/services/9/nodes' },
])
expect(getBreadcrumbs('/services/9/subdomains')).toEqual([
{ label: 'Сервисы', href: '/services' },
{ label: 'Сервис', href: '/services/9' },
{ label: 'Поддомены', href: '/services/9/subdomains' },
])
})
it('does not reuse the section href for the parent crumb', () => {
const crumbs = getBreadcrumbs('/settings/appearance')
const hrefs = crumbs.map((crumb) => crumb.href)
+16 -2
View File
@@ -38,10 +38,24 @@ export function getBreadcrumbs(
return [{ label: 'Панель управления', href: '/' }]
}
if (path.match(/^\/services\/\d+$/)) {
const serviceMatch = path.match(
/^\/services\/(\d+)(?:\/(nodes|health|routing|subdomains))?$/,
)
if (serviceMatch) {
const servicePath = `/services/${serviceMatch[1]}`
const section = serviceMatch[2]
const sectionLabels: Record<string, string> = {
nodes: 'Ноды',
health: 'Health',
routing: 'Маршрутизация',
subdomains: 'Поддомены',
}
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
{ label: dynamicLabels[servicePath] ?? 'Сервис', href: servicePath },
...(section
? [{ label: sectionLabels[section] ?? section, href: path }]
: []),
]
}
+43
View File
@@ -0,0 +1,43 @@
import { QueryClient, QueryObserver } from '@tanstack/react-query'
import { afterEach, describe, expect, it } from 'vitest'
import { bindLiveRefresh, isLiveRefresh, setLiveRefresh } from './live-refresh'
describe('setLiveRefresh', () => {
afterEach(() => {
if (isLiveRefresh()) return
const client = new QueryClient()
setLiveRefresh(client, true)
client.clear()
})
it('гасит интервал и возвращает его, даже если наблюдатель ставит свой снова', () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const unbind = bindLiveRefresh(client)
const observer = new QueryObserver(client, {
queryKey: ['live-refresh'],
queryFn: async () => 'ok',
refetchInterval: 10_000,
})
const unsubscribe = observer.subscribe(() => {})
setLiveRefresh(client, false)
expect(observer.options.refetchInterval).toBe(false)
observer.setOptions({
queryKey: ['live-refresh'],
queryFn: async () => 'ok',
refetchInterval: 10_000,
})
expect(observer.options.refetchInterval).toBe(false)
setLiveRefresh(client, true)
expect(observer.options.refetchInterval).toBe(10_000)
unsubscribe()
unbind()
client.clear()
})
})
+70
View File
@@ -0,0 +1,70 @@
import type { QueryClient } from '@tanstack/react-query'
const STORAGE_KEY = 'cfdm.live-refresh'
type RefetchInterval = number | false | undefined | ((query: unknown) => number | false | undefined)
type IntervalObserver = {
options: { refetchInterval?: RefetchInterval }
setOptions: (options: IntervalObserver['options']) => void
}
const intendedInterval = new WeakMap<IntervalObserver, RefetchInterval>()
function readStoredLiveRefresh() {
try {
return localStorage.getItem(STORAGE_KEY) !== '0'
} catch {
return true
}
}
let liveEnabled = readStoredLiveRefresh()
export function isLiveRefresh() {
return liveEnabled
}
function asObserver(observer: object): IntervalObserver {
return observer as IntervalObserver
}
function remember(observer: IntervalObserver) {
if (!intendedInterval.has(observer)) {
intendedInterval.set(observer, observer.options.refetchInterval)
}
}
function syncObserver(observer: IntervalObserver, live: boolean) {
remember(observer)
const next = live ? intendedInterval.get(observer) : false
if (Object.is(observer.options.refetchInterval, next)) return
observer.setOptions({ ...observer.options, refetchInterval: next })
}
function applyLiveRefresh(queryClient: QueryClient, live: boolean) {
for (const query of queryClient.getQueryCache().getAll()) {
for (const observer of query.observers) {
syncObserver(asObserver(observer), live)
}
}
}
/** Пауза и возобновление уже заданных refetchInterval. Новые запросы без интервала не запускают опрос. */
export function setLiveRefresh(queryClient: QueryClient, enabled: boolean) {
liveEnabled = enabled
try {
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
} catch {
// приватный режим без хранилища — переключатель живёт до перезагрузки
}
applyLiveRefresh(queryClient, enabled)
}
export function bindLiveRefresh(queryClient: QueryClient) {
applyLiveRefresh(queryClient, liveEnabled)
return queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'observerAdded' && event.type !== 'observerOptionsUpdated') return
syncObserver(asObserver(event.observer), liveEnabled)
})
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { deriveSystemAttention, isCertExpiringWithin } from './system-attention'
const NOW = Date.parse('2026-11-02T00:00:00.000Z')
function inDays(days: number) {
return new Date(NOW + days * 86_400_000).toISOString()
}
describe('deriveSystemAttention', () => {
it('считает Down и деградацию в группах и без группы', () => {
const attention = deriveSystemAttention({
now: NOW,
readyIsSuccess: true,
readyIsError: false,
readyStatus: 'ready',
services: {
groups: [
{
services: [
{ id: 1, name: 'Альфа', health_status: 'down' },
{ id: 2, name: 'Бета', health_status: 'up' },
],
},
],
ungrouped: [{ id: 3, name: 'Гамма', health_status: 'degraded' }],
},
})
expect(attention.servicesDown).toBe(1)
expect(attention.servicesDegraded).toBe(1)
expect(attention.incidents.map((item) => item.title)).toEqual(['Альфа', 'Гамма'])
expect(attention.incidents[0]).toMatchObject({
tone: 'destructive',
href: { to: '/services/$serviceId', serviceId: '1' },
})
})
it('берёт сертификаты с остатком не больше 14 дней, включая истёкшие', () => {
expect(isCertExpiringWithin(inDays(14), 14, NOW)).toBe(true)
expect(isCertExpiringWithin(inDays(14.01), 14, NOW)).toBe(false)
expect(isCertExpiringWithin(inDays(-1), 14, NOW)).toBe(true)
const attention = deriveSystemAttention({
now: NOW,
readyIsSuccess: false,
readyIsError: false,
certificates: [
{ id: 10, hostname: 'soon.example', expires_at: inDays(3), service_id: 7 },
{ id: 11, hostname: 'later.example', expires_at: inDays(20), service_id: 7 },
{ id: 12, hostname: 'dead.example', expires_at: inDays(-2), service_id: null },
{ id: 13, hostname: 'unknown.example', expires_at: null },
],
certSummary: [
['warning', 2],
['expired', 1],
['ok', 4],
],
notifications: [
{ kind: 'sync', title: 'Sync failed', message: 'timeout' },
{ kind: 'info', title: 'Готово', message: 'ok' },
],
domains: [{ group_id: null }, { group_id: 1 }],
})
expect(attention.certsExpiring).toBe(2)
expect(attention.certWarnings).toBe(3)
expect(attention.failedJobs).toBe(1)
expect(attention.ungroupedCount).toBe(1)
expect(attention.incidents.map((item) => item.title)).toEqual([
'dead.example',
'soon.example',
])
expect(attention.incidents[0]?.href).toEqual({ to: '/certificates' })
expect(attention.incidents[1]?.href).toEqual({
to: '/services/$serviceId',
serviceId: '7',
})
})
})
+188
View File
@@ -0,0 +1,188 @@
/** Общее окно «истекает» для точек сайдбара и инцидентов монитора. */
export const CERT_EXPIRING_WITHIN_DAYS = 14
const MS_PER_DAY = 86_400_000
export type AttentionService = {
id: number
name: string
health_status: string
}
export type AttentionServiceGroups = {
groups: { services: AttentionService[] }[]
ungrouped: AttentionService[]
}
export type AttentionCertificate = {
id: number
hostname: string
expires_at: string | null
service_id?: number | null
}
export type AttentionNotification = {
kind: string
title: string
message: string
}
export type AttentionDomain = {
group_id: number | null
}
export type SystemIncident = {
id: string
title: string
detail: string
tone: 'destructive' | 'warning'
href:
| { to: '/services/$serviceId'; serviceId: string }
| { to: '/certificates' }
}
export type SystemAttention = {
servicesDown: number
servicesDegraded: number
certWarnings: number
certsExpiring: number
failedJobs: number
ungroupedCount: number
readyOk: boolean
readyDegraded: boolean
incidents: SystemIncident[]
}
type DeriveInput = {
services?: AttentionServiceGroups
certificates?: AttentionCertificate[]
certSummary?: [string, number][]
notifications?: AttentionNotification[]
domains?: AttentionDomain[]
readyStatus?: string
readyIsSuccess: boolean
readyIsError: boolean
now?: number
}
function countByStatus(
summary: [string, number][] | undefined,
statuses: string[],
) {
if (!summary) return 0
return summary
.filter(([status]) => statuses.includes(status))
.reduce((sum, [, count]) => sum + count, 0)
}
function daysUntilExpiry(expiresAt: string, now: number): number | null {
const ts = new Date(expiresAt).getTime()
if (Number.isNaN(ts)) return null
return (ts - now) / MS_PER_DAY
}
/** Истёкшие тоже входят: дней до срока ≤ 14, включая отрицательные. */
export function isCertExpiringWithin(
expiresAt: string | null,
withinDays: number,
now: number,
) {
if (!expiresAt) return false
const days = daysUntilExpiry(expiresAt, now)
return days != null && days <= withinDays
}
function eachService(
data: AttentionServiceGroups | undefined,
visit: (service: AttentionService) => void,
) {
if (!data) return
for (const group of data.groups) {
for (const service of group.services) visit(service)
}
for (const service of data.ungrouped) visit(service)
}
function certDetail(days: number) {
if (days < 0) return 'Истёк'
if (days < 1) return 'Сегодня'
return `${Math.ceil(days)} дн.`
}
function compareRu(a: string, b: string) {
return a.localeCompare(b, 'ru')
}
export function deriveSystemAttention(input: DeriveInput): SystemAttention {
const now = input.now ?? Date.now()
let servicesDown = 0
let servicesDegraded = 0
const serviceIncidents: SystemIncident[] = []
eachService(input.services, (service) => {
if (service.health_status === 'down') {
servicesDown += 1
serviceIncidents.push({
id: `service:${service.id}`,
title: service.name || `Сервис ${service.id}`,
detail: 'Недоступен',
tone: 'destructive',
href: { to: '/services/$serviceId', serviceId: String(service.id) },
})
return
}
if (service.health_status === 'degraded') {
servicesDegraded += 1
serviceIncidents.push({
id: `service:${service.id}`,
title: service.name || `Сервис ${service.id}`,
detail: 'Деградация',
tone: 'warning',
href: { to: '/services/$serviceId', serviceId: String(service.id) },
})
}
})
serviceIncidents.sort((a, b) => {
if (a.tone !== b.tone) return a.tone === 'destructive' ? -1 : 1
return compareRu(a.title, b.title)
})
const certIncidents: { incident: SystemIncident; days: number }[] = []
for (const cert of input.certificates ?? []) {
if (!cert.expires_at) continue
const days = daysUntilExpiry(cert.expires_at, now)
if (days == null || days > CERT_EXPIRING_WITHIN_DAYS) continue
const serviceId = cert.service_id
certIncidents.push({
days,
incident: {
id: `cert:${cert.id}`,
title: cert.hostname,
detail: certDetail(days),
tone: days < 0 ? 'destructive' : 'warning',
href:
serviceId != null
? { to: '/services/$serviceId', serviceId: String(serviceId) }
: { to: '/certificates' },
},
})
}
certIncidents.sort((a, b) => a.days - b.days || compareRu(a.incident.title, b.incident.title))
const failedJobs = (input.notifications ?? []).filter((entry) =>
/fail|error/i.test(`${entry.kind} ${entry.title} ${entry.message}`),
).length
return {
servicesDown,
servicesDegraded,
certWarnings: countByStatus(input.certSummary, ['warning', 'expired', 'error']),
certsExpiring: certIncidents.length,
failedJobs,
ungroupedCount: (input.domains ?? []).filter((domain) => domain.group_id == null).length,
readyOk: input.readyStatus === 'ready' && input.readyIsSuccess,
readyDegraded: input.readyStatus === 'degraded' || input.readyIsError,
incidents: [...serviceIncidents, ...certIncidents.map((item) => item.incident)],
}
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { queryOptions } from '@tanstack/react-query'
import { queryOptions, type QueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
failoverLogSchema,
@@ -210,3 +210,17 @@ export async function createOriginHealthCheck(body: Record<string, unknown>) {
export async function listOriginHealthChecks() {
return api.get('/api/v1/health-checks')
}
export function invalidateServiceDetail(queryClient: QueryClient, id: number) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: ['domains'] }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
])
}
+104 -5
View File
@@ -1,12 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'
import { useCallback, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useCallback, useMemo, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { CircleAlertIcon, SearchIcon, ShieldCheckIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
import { api } from '@/lib/api-client'
import { sqliteUtcToIso } from '@/lib/format'
import type { Certificate } from '@/lib/schemas'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { IncidentAlert } from '@/components/incident-alert'
import { ResourcePage } from '@/components/reui-kit'
import { CertKpiStats } from '@/components/cert-kpi-stats'
import {
@@ -18,6 +22,7 @@ import {
useCertFilterFields,
} from '@/components/columns/certificates-columns'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@cfdm/ui/components/button'
export const Route = createFileRoute('/_auth/certificates')({
loader: ({ context: { queryClient } }) =>
@@ -29,6 +34,7 @@ export const Route = createFileRoute('/_auth/certificates')({
})
function CertificatesPage() {
const navigate = useNavigate()
const [filters, setFilters] = useState<Filter[]>(createDefaultCertFilters)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
@@ -59,6 +65,7 @@ function CertificatesPage() {
const filterFields = useCertFilterFields()
const columns = useCertificateColumns()
const expiredCert = useMemo(() => pickHeaviestExpired(certs), [certs])
const primaryAction = (
<LoadingButton
@@ -77,6 +84,13 @@ function CertificatesPage() {
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
actions={primaryAction}
/>
{expiredCert ? (
<IncidentAlert
title={expiredCertTitle(expiredCert)}
description={expiredCertDescription(expiredCert)}
onShow={() => showCertificate(expiredCert, navigate, setActiveTab)}
/>
) : null}
<CertKpiStats
summary={summary}
total={certs?.length}
@@ -104,12 +118,97 @@ function CertificatesPage() {
isError={isError}
error={error}
onRetry={refetch}
errorState={{
icon: CircleAlertIcon,
title: 'Не удалось загрузить сертификаты',
description: error?.message ?? 'Повторите запрос.',
action: (
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
</Button>
),
}}
filteredEmptyState={{
icon: SearchIcon,
title: 'Нет совпадений',
description: 'Измените поиск или сбросьте фильтры.',
resetLabel: 'Сбросить фильтры',
}}
onFilteredEmptyReset={() => setActiveTab('all')}
emptyState={{
title: 'Сертификаты не найдены',
description: 'Сертификаты появятся после проверки доменов',
action: primaryAction,
icon: ShieldCheckIcon,
title: 'Нет данных',
description: 'Сертификаты появятся после первой проверки сервисов.',
action: (
<Button
type="button"
variant="outline"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
Проверить
</Button>
),
}}
/>
</PageShell>
)
}
function pickHeaviestExpired(
certs: Certificate[] | undefined,
): Certificate | null {
const expired = (certs ?? []).filter((cert) => cert.status === 'expired')
if (expired.length === 0) return null
return [...expired].sort((left, right) => {
const delta = expiryMs(left.expires_at) - expiryMs(right.expires_at)
if (delta !== 0) return delta
return left.hostname.localeCompare(right.hostname, 'ru')
})[0]
}
function expiryMs(value: string | null): number {
if (!value) return Number.POSITIVE_INFINITY
const ms = new Date(sqliteUtcToIso(value) ?? value).getTime()
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms
}
function expiredCertTitle(cert: Certificate): string {
const ms = expiryMs(cert.expires_at)
if (!Number.isFinite(ms)) return `Сертификат ${cert.hostname} истёк.`
const days = Math.floor((Date.now() - ms) / 86_400_000)
if (days <= 0) return `Сертификат ${cert.hostname} истёк.`
return `Сертификат ${cert.hostname} истёк ${days} ${pluralDays(days)} назад.`
}
function expiredCertDescription(cert: Certificate): string | undefined {
const parts = [
cert.service_name ? `Сервис ${cert.service_name}` : null,
cert.last_error,
].filter((part): part is string => Boolean(part))
if (parts.length === 0) return undefined
return `${parts.join('. ')}.`
}
function pluralDays(days: number): string {
const mod10 = days % 10
const mod100 = days % 100
if (mod10 === 1 && mod100 !== 11) return 'день'
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'дня'
return 'дней'
}
function showCertificate(
cert: Certificate,
navigate: ReturnType<typeof useNavigate>,
setActiveTab: (tabId: string) => void,
): void {
if (cert.service_id != null) {
navigate({
to: '/services/$serviceId',
params: { serviceId: String(cert.service_id) },
})
return
}
setActiveTab('expired')
}
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
@@ -10,7 +10,8 @@ import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, domainServiceBindingsQueryOptions, subdomainKeys } from '@/queries'
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
import { PageShell } from '@/components/page-shell'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import { ResourcePage } from '@/components/reui-kit'
import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome'
import {
DNS_TABS,
createDefaultDnsFilters,
@@ -154,77 +155,63 @@ function DnsPage() {
return list.filter((r) => r.name.toLowerCase().includes(query))
}, [records, host])
const headerActions = (
<>
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
<LoadingButton
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
)
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title={`${domain?.zone_name ?? ''} — DNS`}
description={
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
}
actions={headerActions}
>
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link to="/domains/$domainId" params={{ domainId }} />
}
>
← Обзор домена
</Button>
</DetailPanel.Header>
<DetailPanel.Section>
<ResourcePage
title="DNS-записи"
description={domain?.zone_name ?? ''}
hideHeader
tabs={DNS_TABS.map((tab) => ({ ...tab }))}
tabFilter={dnsTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultDnsFilters())}
getFilterFieldValue={dnsFilterFieldValue}
columns={columns}
data={displayRecords}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void refetchDomain()
void refetchRecords()
}}
emptyState={{
title: 'DNS-записи не найдены',
description: 'Синхронизируйте зону или создайте запись вручную',
action: (
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel>
<DomainDetailChrome domainId={domainId} activeTab="dns">
{() => (
<div className="flex flex-col gap-4">
{host ? (
<p className="text-muted-foreground text-sm">Фильтр по хосту: {host}</p>
) : null}
<ResourcePage
title="DNS-записи"
description={domain?.zone_name ?? ''}
hideHeader
tabs={DNS_TABS.map((tab) => ({ ...tab }))}
tabFilter={dnsTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultDnsFilters())}
getFilterFieldValue={dnsFilterFieldValue}
columns={columns}
data={displayRecords}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void refetchDomain()
void refetchRecords()
}}
toolbarExtra={
<>
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
<LoadingButton
variant="outline"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
}
emptyState={{
title: 'DNS-записи не найдены',
description: 'Синхронизируйте зону или создайте запись вручную',
action: (
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
),
}}
/>
</div>
)}
</DomainDetailChrome>
<FormSheet
open={sheetOpen}
@@ -1,19 +1,9 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
FolderTreeIcon,
GlobeIcon,
Link2Icon,
ServerIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { Filter } from '@/components/reui/filters'
import {
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
healthStatusKeys,
runHealthCheck,
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
@@ -21,9 +11,7 @@ import {
import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import { ResourcePage } from '@/components/reui-kit'
import {
SUBDOMAIN_TABS,
createDefaultSubdomainFilters,
@@ -38,15 +26,23 @@ import {
} from '@/components/subdomain-edit-sheet'
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { StatusBadge } from '@/components/status-badge'
import { formatDate } from '@/lib/format'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome'
import { type DomainSectionTab } from '@/components/domains/domain-detail-tabs'
import { Button } from '@cfdm/ui/components/button'
import { TabsContent } from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
validateSearch: (search: Record<string, unknown>): { tab?: DomainSectionTab } => {
const tab = search.tab
if (
tab === 'availability' ||
tab === 'subdomains' ||
tab === 'bindings' ||
tab === 'overview'
) {
return { tab }
}
return {}
},
loader: async ({ context: { queryClient }, params }) => {
const id = Number(params.domainId)
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
@@ -61,70 +57,28 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
component: DomainOverviewPage,
})
function DomainPageSkeleton() {
return (
<div className="flex flex-col gap-4">
<TableSkeleton rows={1} cols={2} />
<div className="grid gap-4 md:grid-cols-3">
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
</div>
<TableSkeleton rows={5} cols={5} />
</div>
)
}
function DomainOverviewPage() {
const { domainId } = Route.useParams()
const { tab } = Route.useSearch()
const id = Number(domainId)
const queryClient = useQueryClient()
const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editOpen, setEditOpen] = useState(false)
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
const [activeTab, setActiveTab] = useState('overview')
const {
domain,
bindings,
services,
serviceGroupById,
subdomainRows,
isLoading,
isError,
error,
refetch,
syncMutation,
createSubdomainMutation,
bindings,
updateSubdomainMutation,
deleteSubdomainMutation,
linkServiceMutation,
} = useDomainPage(id)
const runCheckMutation = useMutation({
mutationFn: runHealthCheck,
onSuccess: async (data) => {
toast.success(`Проверено IP: ${data.checked}`)
await queryClient.invalidateQueries({ queryKey: healthStatusKeys.all })
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось запустить проверку',
)
},
})
function openCreateSheet() {
setSheetMode('create')
setEditTarget(null)
setSheetOpen(true)
}
function openEditSheet(row: SubdomainTableRow) {
setSheetMode('edit')
setEditTarget(row)
setSheetOpen(true)
setEditOpen(true)
}
const filterFields = useSubdomainFilterFields()
@@ -146,13 +100,7 @@ function DomainOverviewPage() {
return String(row.serviceLinks[0].serviceId)
}
async function handleSheetSubmit(values: SubdomainEditValues) {
if (sheetMode === 'create') {
await createSubdomainMutation.mutateAsync(values.name)
setSheetOpen(false)
return
}
async function handleEditSubmit(values: SubdomainEditValues) {
if (!editTarget) return
const nameChanged = values.name !== editTarget.subdomain.name
@@ -185,207 +133,69 @@ function DomainOverviewPage() {
})
}
setSheetOpen(false)
setEditOpen(false)
}
const isSheetSaving =
createSubdomainMutation.isPending ||
updateSubdomainMutation.isPending ||
linkServiceMutation.isPending
updateSubdomainMutation.isPending || linkServiceMutation.isPending
const metricCards = useMemo(() => {
if (!domain) return []
return [
{
id: 'status',
icon: <GlobeIcon aria-hidden="true" />,
label: 'Статус зоны',
description: domain.status,
footer: <StatusBadge status={domain.status} />,
},
{
id: 'group',
icon: <FolderTreeIcon aria-hidden="true" />,
label: 'Группа',
description: domain.group_id ? 'Домен в группе' : 'Без группы',
footer: domain.group_id ? (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
Открыть группу
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
),
},
{
id: 'bindings',
icon: <Link2Icon aria-hidden="true" />,
label: 'Привязки',
description: 'Сервисы в зоне',
footer: (
<span className="text-foreground text-lg font-semibold tabular-nums">
{bindings.length}
</span>
),
},
{
id: 'sync',
icon: <ServerIcon aria-hidden="true" />,
label: 'Синхронизация',
description: 'Последняя синхронизация с Cloudflare',
footer: (
<span className="text-foreground text-sm font-medium tabular-nums">
{formatDate(domain.last_synced_at)}
</span>
),
},
]
}, [bindings.length, domain])
const headerActions = (
<>
<LoadingButton
variant="outline"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать
</LoadingButton>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
DNS
</Button>
<LoadingButton
variant="outline"
onClick={() => runCheckMutation.mutate()}
isLoading={runCheckMutation.isPending}
loadingLabel="Проверка…"
>
Проверить сейчас
</LoadingButton>
</>
)
const activeTab: DomainSectionTab = tab ?? 'overview'
return (
<PageShell>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<DomainPageSkeleton />}
>
{domain && (
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Карточка домена"
actions={headerActions}
/>
<CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
label: 'Поддомены',
count: subdomainRows.length,
},
{
id: 'bindings',
label: 'Привязки',
count: bindings.length,
},
]}
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
>
<TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} />
</TabsContent>
<TabsContent value="availability" className="flex flex-col gap-4">
<DomainAvailabilityPanel domainId={id} />
</TabsContent>
<TabsContent value="subdomains" className="flex flex-col gap-4">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() =>
setFilters(createDefaultSubdomainFilters())
}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<Button type="button" onClick={openCreateSheet}>
<DomainDetailChrome domainId={domainId} activeTab={activeTab}>
{({ openCreateSubdomain }) => (
<>
{activeTab === 'availability' ? (
<DomainAvailabilityPanel domainId={id} />
) : null}
{activeTab === 'subdomains' && domain ? (
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((item) => ({ ...item }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSubdomainFilters())}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" variant="outline" onClick={openCreateSubdomain}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<DomainBindingsPanel bindings={bindings} />
</TabsContent>
</CountedLineTabs>
<SubdomainEditSheet
mode={sheetMode}
subdomain={editTarget?.subdomain ?? null}
zoneName={domain.zone_name}
services={services}
serviceGroupById={serviceGroupById}
currentServiceId={
editTarget ? resolveServiceId(editTarget) : 'none'
}
serviceLinks={editTarget?.serviceLinks ?? []}
open={sheetOpen}
isSaving={isSheetSaving}
onOpenChange={setSheetOpen}
onSubmit={handleSheetSubmit}
/>
</DetailPanel>
),
}}
/>
) : null}
{activeTab === 'bindings' ? (
<DomainBindingsPanel bindings={bindings} />
) : null}
{domain ? (
<SubdomainEditSheet
mode="edit"
subdomain={editTarget?.subdomain ?? null}
zoneName={domain.zone_name}
services={services}
serviceGroupById={serviceGroupById}
currentServiceId={editTarget ? resolveServiceId(editTarget) : 'none'}
serviceLinks={editTarget?.serviceLinks ?? []}
open={editOpen}
isSaving={isSheetSaving}
onOpenChange={setEditOpen}
onSubmit={handleEditSubmit}
/>
) : null}
</>
)}
</QueryState>
</DomainDetailChrome>
</PageShell>
)
}
+120 -11
View File
@@ -1,18 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { CircleAlertIcon, GlobeIcon, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput } from '@/lib/schemas'
import { formatDate, sqliteUtcToIso } from '@/lib/format'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput, type DomainListItem } from '@/lib/schemas'
import {
bulkUpdateDomains,
dnsKeys,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
serviceBindingKeys,
subdomainKeys,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
@@ -41,7 +45,6 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
import type { DomainListItem } from '@/lib/schemas'
export const Route = createFileRoute('/_auth/domains/')({
loader: ({ context: { queryClient } }) =>
@@ -54,6 +57,7 @@ export const Route = createFileRoute('/_auth/domains/')({
function DomainsPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [syncOpen, setSyncOpen] = useState(false)
const [importGroupId, setImportGroupId] = useState('')
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
@@ -112,6 +116,22 @@ function DomainsPage() {
},
})
const syncMutation = useMutation({
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: dnsKeys.all }),
queryClient.invalidateQueries({ queryKey: subdomainKeys.all }),
])
setSyncOpen(false)
toast.success('Синхронизация завершена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
},
})
const bulkMutation = useMutation({
mutationFn: (body: BulkUpdateDomainsInput) => bulkUpdateDomains(body),
onSuccess: (data) => {
@@ -142,17 +162,36 @@ function DomainsPage() {
form.setValue('group_id', next)
}
const freshness = domainFreshnessLine(domains)
const primaryAction = (
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
<>
<Button type="button" variant="outline" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
<LoadingButton
type="button"
onClick={() => setSyncOpen(true)}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
)
return (
<PageShell>
<PageHeader
title="Домены"
description="Импортированные зоны Cloudflare"
description={
<span className="flex flex-col gap-1">
<span>Импортированные зоны Cloudflare</span>
{freshness ? (
<span className="tabular-nums">{freshness}</span>
) : null}
</span>
}
actions={primaryAction}
/>
<DomainKpiStats
@@ -181,6 +220,33 @@ function DomainsPage() {
isError={isError}
error={error}
onRetry={refetch}
errorState={{
icon: CircleAlertIcon,
title: 'Ошибка Cloudflare API',
description: cloudflareErrorDescription(error),
action: (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
</Button>
<Button
type="button"
variant="outline"
nativeButton={false}
render={<Link to="/settings/integrations" />}
>
Открыть интеграции
</Button>
</div>
),
}}
filteredEmptyState={{
icon: SearchIcon,
title: 'Нет совпадений',
description: 'Измените поиск или сбросьте фильтры.',
resetLabel: 'Сбросить фильтры',
}}
onFilteredEmptyReset={() => setActiveTab('all')}
enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => (
<DomainsBulkToolbar
@@ -209,11 +275,17 @@ function DomainsPage() {
/>
)}
emptyState={{
title: 'Домены не импортированы',
description: 'Импортируйте зону из аккаунта Cloudflare',
icon: GlobeIcon,
title: 'Нет доменов',
description:
'Подключите токен Cloudflare в настройках и запустите синхронизацию.',
action: (
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
<Button
type="button"
variant="outline"
onClick={() => setSyncOpen(true)}
>
Синхронизировать
</Button>
),
}}
@@ -271,6 +343,17 @@ function DomainsPage() {
</FieldGroup>
</FormSheet>
<ConfirmDialog
open={syncOpen}
onOpenChange={setSyncOpen}
title="Синхронизировать с Cloudflare?"
description="Будут запрошены зоны и записи DNS. Локальные привязки сервисов не изменятся."
confirmLabel="Синхронизировать"
confirmVariant="default"
onConfirm={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -290,3 +373,29 @@ function DomainsPage() {
</PageShell>
)
}
function domainFreshnessLine(
domains: DomainListItem[] | undefined,
): string | null {
if (!domains) return null
const zoneCount = domains.length
let latestMs = Number.NEGATIVE_INFINITY
let latest: string | null = null
for (const domain of domains) {
if (!domain.last_synced_at) continue
const ms = new Date(
sqliteUtcToIso(domain.last_synced_at) ?? domain.last_synced_at,
).getTime()
if (Number.isNaN(ms) || ms <= latestMs) continue
latestMs = ms
latest = domain.last_synced_at
}
if (!latest) return `Синхронизация ещё не выполнялась · зон ${zoneCount}`
return `Синхронизация ${formatDate(latest)} · зон ${zoneCount}`
}
function cloudflareErrorDescription(error: Error | null): string {
const hint = 'Проверьте токен в «Настройки → Интеграции».'
if (!error?.message) return hint
return `${error.message} ${hint}`
}
+10 -2
View File
@@ -1,4 +1,4 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { FolderTreeIcon } from 'lucide-react'
@@ -57,9 +57,17 @@ export const Route = createFileRoute('/_auth/groups')({
queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
]),
component: GroupsPage,
component: GroupsRoute,
})
function GroupsRoute() {
const isDetail = useRouterState({
select: (state) => /^\/groups\/[^/]+/.test(state.location.pathname),
})
if (isDetail) return <Outlet />
return <GroupsPage />
}
function GroupsPage() {
const { view: viewParam } = Route.useSearch()
const view = viewParam === 'board' ? 'board' : 'catalog'
+104 -167
View File
@@ -1,6 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState, useEffect } from 'react'
import { toast } from 'sonner'
import {
ActivityIcon,
AlertTriangleIcon,
@@ -14,39 +15,37 @@ import {
import {
certificatesQueryOptions,
certSummaryQueryOptions,
dnsKeys,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
opsSummaryQueryOptions,
serviceGroupsQueryOptions,
subdomainKeys,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import {
CertStatusChart,
GroupDomainsChart,
OpsDashboard,
QuickActionGrid,
AttentionQueue,
ExpiringCertGrid,
HealthProblemGrid,
UngroupedDomainGrid,
type KpiStatCard,
type QuickActionItem,
} from '@/components/reui-kit'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Button } from '@cfdm/ui/components/button'
import { formatRelative } from '@/lib/format'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
import { api } from '@/lib/api-client'
import type { ServiceView } from '@/lib/schemas'
export const Route = createFileRoute('/_auth/')({
loader: ({ context: { queryClient } }) =>
Promise.all([
Promise.allSettled([
queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(certSummaryQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()),
@@ -57,13 +56,50 @@ export const Route = createFileRoute('/_auth/')({
component: DashboardPage,
})
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : undefined
}
function DashboardPage() {
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
const queryClient = useQueryClient()
const {
data: domains,
isLoading: domainsLoading,
isError: domainsFailed,
error: domainsQueryError,
refetch: refetchDomains,
} = useQuery(domainsListQueryOptions())
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
const { data: certs } = useQuery(certificatesQueryOptions())
const {
data: certs,
isError: certsFailed,
error: certsQueryError,
refetch: refetchCerts,
} = useQuery(certificatesQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
const {
data: serviceData,
isError: servicesFailed,
error: servicesQueryError,
refetch: refetchServices,
} = useQuery(serviceGroupsQueryOptions())
const { data: ops } = useQuery(opsSummaryQueryOptions())
const syncMutation = useMutation({
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
onSuccess: async () => {
toast.success('Синхронизация завершена')
await Promise.all([
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: subdomainKeys.all }),
queryClient.invalidateQueries({ queryKey: dnsKeys.all }),
])
},
onError: (error: unknown) => {
toast.error(
error instanceof Error ? error.message : 'Не удалось синхронизировать домены',
)
},
})
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
@@ -147,54 +183,33 @@ function DashboardPage() {
const expiringCerts = useMemo(() => {
const warnMs = 14 * 24 * 60 * 60 * 1000
return (certs ?? [])
.filter((c) => c.expires_at)
.map((c) => ({ cert: c, ts: new Date(c.expires_at as string).getTime() }))
.filter((e) => !Number.isNaN(e.ts) && e.ts - nowMs <= warnMs)
.filter((cert) => cert.expires_at)
.map((cert) => ({
cert,
ts: new Date(cert.expires_at as string).getTime(),
}))
.filter((entry) => !Number.isNaN(entry.ts) && entry.ts - nowMs <= warnMs)
.sort((a, b) => a.ts - b.ts)
.slice(0, 8)
.map((entry) => entry.cert)
}, [certs, nowMs])
const ungroupedDomains = useMemo(
() => (domains ?? []).filter((d) => d.group_id == null).slice(0, 5),
() => (domains ?? []).filter((domain) => domain.group_id == null),
[domains],
)
const attentionServices = useMemo(() => {
const list: Array<{
id: number
name: string
health_status: string
health_latency_ms: number | null
}> = []
const list: ServiceView[] = []
const pushIfUnhealthy = (service: ServiceView) => {
if (service.health_status === 'down' || service.health_status === 'degraded') {
list.push(service)
}
}
for (const group of serviceData?.groups ?? []) {
for (const service of group.services) {
if (
service.health_status === 'down' ||
service.health_status === 'degraded'
) {
list.push({
id: service.id,
name: service.name,
health_status: service.health_status,
health_latency_ms: service.health_latency_ms,
})
}
}
for (const service of group.services) pushIfUnhealthy(service)
}
for (const service of serviceData?.ungrouped ?? []) {
if (
service.health_status === 'down' ||
service.health_status === 'degraded'
) {
list.push({
id: service.id,
name: service.name,
health_status: service.health_status,
health_latency_ms: service.health_latency_ms,
})
}
}
return list.slice(0, 8)
for (const service of serviceData?.ungrouped ?? []) pushIfUnhealthy(service)
return list
}, [serviceData])
const attentionCount = attentionServices.length
@@ -224,11 +239,13 @@ function DashboardPage() {
{
id: 'domains',
label: 'Домены',
value: domains?.length ?? 0,
value: domainsFailed && domains == null ? '—' : (domains?.length ?? 0),
hint:
ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
domainsFailed && domains == null
? undefined
: ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
icon: <GlobeIcon aria-hidden />,
iconClassName: 'text-info',
variant: ungroupedCount > 0 ? 'warning' : 'default',
@@ -258,30 +275,15 @@ function DashboardPage() {
iconClassName: 'text-info',
to: '/services',
},
{
id: 'healthy',
label: 'Healthy',
value: ops?.healthy ?? 0,
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'unhealthy',
label: 'Unhealthy',
value: ops?.unhealthy ?? 0,
hint: `активных failover: ${ops?.active_failovers ?? 0}`,
variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default',
icon: <AlertTriangleIcon aria-hidden />,
iconClassName: 'text-destructive',
},
{
id: 'failovers',
label: 'Failover',
value: ops?.active_failovers ?? 0,
hint: 'активные переключения',
variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default',
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning',
},
]
return (
@@ -289,6 +291,16 @@ function DashboardPage() {
<PageHeader
title="Панель управления"
description="Обзор доменов, групп, сервисов и сертификатов Cloudflare"
actions={
<LoadingButton
type="button"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать
</LoadingButton>
}
/>
<OpsDashboard
isLoading={isLoading}
@@ -303,6 +315,7 @@ function DashboardPage() {
}
charts={
<>
{/* health-log — журнал проб сервиса, не ряд Healthy/Unhealthy за 24 часа */}
<CertStatusChart data={statusChartData} />
<GroupDomainsChart data={groupChartData} />
</>
@@ -319,6 +332,11 @@ function DashboardPage() {
countVariant: 'destructive-light',
emptyTitle: 'Нет проблем',
emptyDescription: 'Нет сервисов со статусом Down или Slow',
isError: servicesFailed && serviceData == null,
errorMessage: errorMessage(servicesQueryError),
onRetry: () => {
void refetchServices()
},
emptyAction: (
<Button
variant="outline"
@@ -329,45 +347,7 @@ function DashboardPage() {
К сервисам
</Button>
),
children: (
<ItemGroup className="gap-2">
{attentionServices.map((service) => (
<Item key={service.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{service.name}
</ItemTitle>
</ItemContent>
<ItemActions>
<HealthCheckBadge
status={
(service.health_status as
| 'up'
| 'down'
| 'degraded'
| 'unknown') ?? 'unknown'
}
latencyMs={service.health_latency_ms}
size="xs"
/>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/services"
search={{ serviceId: service.id }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
children: <HealthProblemGrid services={attentionServices} />,
},
{
id: 'certs',
@@ -378,6 +358,11 @@ function DashboardPage() {
countVariant: 'warning-light',
emptyTitle: 'Нет истечений',
emptyDescription: 'В пределах 14 дней истечений нет',
isError: certsFailed && certs == null,
errorMessage: errorMessage(certsQueryError),
onRetry: () => {
void refetchCerts()
},
emptyAction: (
<Button
variant="outline"
@@ -388,33 +373,7 @@ function DashboardPage() {
К сертификатам
</Button>
),
children: (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
<Item key={cert.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{cert.hostname}
</ItemTitle>
<span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(new Date(ts).toISOString())}
</span>
</ItemContent>
<ItemActions>
<StatusBadge status={cert.status} />
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/certificates" />}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
children: <ExpiringCertGrid certs={expiringCerts} />,
},
{
id: 'ungrouped',
@@ -425,6 +384,11 @@ function DashboardPage() {
countVariant: 'warning-light',
emptyTitle: 'Всё сгруппировано',
emptyDescription: 'Все домены в группах',
isError: domainsFailed && domains == null,
errorMessage: errorMessage(domainsQueryError),
onRetry: () => {
void refetchDomains()
},
emptyAction: (
<Button
variant="outline"
@@ -435,34 +399,7 @@ function DashboardPage() {
К доменам
</Button>
),
children: (
<ItemGroup className="gap-2">
{ungroupedDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="min-w-0">
<ItemTitle className="truncate font-medium">
{domain.zone_name}
</ItemTitle>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
children: <UngroupedDomainGrid domains={ungroupedDomains} />,
},
]}
/>
@@ -1,11 +1,12 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { ServiceHealthSection } from '@/components/services/service-health-section'
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
component: ServiceHealthPage,
})
function ServiceHealthPage() {
const { serviceId } = Route.useParams()
return <ServiceHealthSection serviceId={serviceId} />
}
@@ -1,458 +1,12 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
ActivityIcon,
GlobeIcon,
NetworkIcon,
PencilIcon,
ServerIcon,
} from 'lucide-react'
import { createFileRoute } from '@tanstack/react-router'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { EmptyState } from '@/components/empty-state'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { LoadingButton } from '@/components/loading-button'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import {
ServiceDetailGrid,
type ServiceFqdnRow,
} from '@/components/services/service-detail-grid'
import { LbModeTile } from '@/components/services/service-unit-card'
import {
KpiStatGrid,
ServiceFailoverPanel,
ServiceHealthMonitor,
} from '@/components/reui-kit'
import { api } from '@/lib/api-client'
import { hasSharedPool, uniqueIpCount } from '@/lib/failover-events'
import {
enabledHealthProviders,
providerHealthStatuses,
resolveServiceDisplayHealth,
} from '@/lib/health-log'
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import {
createServiceNode,
deleteServiceNode,
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceDetailKeys,
serviceFailoverLogQueryOptions,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceHealthLogQueryOptions,
serviceKeys,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { ServiceAssetSection } from '@/components/services/service-asset-section'
export const Route = createFileRoute('/_auth/services/$serviceId/')({
component: ServiceDetailPage,
})
interface OverviewPayload {
routing_strategy?: string
active_addresses?: string[]
nodes?: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
consecutive_failures: number
last_failure_reason: string | null
last_check_at?: string | null
}>
}
function ServiceDetailPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const failoverLogQuery = useQuery(serviceFailoverLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions())
const service = viewQuery.data
const overview = overviewQuery.data as OverviewPayload | undefined
const logItems = useMemo(
() => logQuery.data?.items ?? [],
[logQuery.data?.items],
)
const failoverHistory = useMemo(
() => failoverLogQuery.data?.items ?? [],
[failoverLogQuery.data?.items],
)
const failoverBindings = useMemo(
() =>
(service?.domains ?? []).map((domain) => ({
fqdn: domain.fqdn,
configured: domain.target_ips,
active: domain.active_ips ?? [],
})),
[service?.domains],
)
const uniqueEnabledIps = useMemo(
() =>
(service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
[service?.ips, service?.ip_enabled],
)
const displayHealth = useMemo(
() =>
resolveServiceDisplayHealth(
service?.health_status,
service?.ip_health ?? [],
logItems,
),
[service?.health_status, service?.ip_health, logItems],
)
const showPoolPanel =
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [togglingIp, setTogglingIp] = useState<string | null>(null)
const [changeIp, setChangeIp] = useState<{
bindingId: number
ip?: string
} | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const nodeForm = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const groups = groupsQuery.data
? [...groupsQuery.data.groups]
: []
async function invalidateService() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
])
}
const updateMutation = useMutation({
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: async () => {
await invalidateService()
setEditOpen(false)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => setSaving(false),
})
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/api/v1/services/${id}`),
onSuccess: async () => {
await invalidateService()
toast.success('Сервис удалён')
await navigate({ to: '/services' })
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
})
const toggleIpMutation = useMutation({
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
onSuccess: async (_data, { enabled }) => {
await invalidateService()
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
},
onSettled: () => setTogglingIp(null),
})
const createNodeMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await invalidateService()
setAddNodeOpen(false)
nodeForm.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteNodeMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await invalidateService()
},
})
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error
const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []),
[service],
)
const providerStatuses = useMemo(
() => providerHealthStatuses(logItems, enabledProviders),
[logItems, enabledProviders],
)
return (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void viewQuery.refetch()
void overviewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : (
<div className="@container flex w-full flex-col gap-4 md:gap-6">
<PageHeader
title={service.name}
description="Domain → Service → Node → Health → Failover"
actions={
<>
<LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
<HealthCheckBadge status={displayHealth} />
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Изменить"
onClick={() => setEditOpen(true)}
/>
}
>
<PencilIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>Изменить</TooltipContent>
</Tooltip>
</>
}
/>
<KpiStatGrid
cards={[
{
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: displayHealth === 'up' ? 'OK' : displayHealth,
variant:
displayHealth === 'down'
? 'destructive'
: displayHealth === 'degraded'
? 'warning'
: 'default',
iconClassName:
displayHealth === 'down'
? 'text-destructive'
: displayHealth === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
},
{
id: 'ip',
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: showPoolPanel
? `${service.active_ips.length} в пуле`
: uniqueEnabledIps.length <= 1
? 'без балансировки'
: service.ips.join(', ') || 'нет',
},
{
id: 'pool',
icon: <ServerIcon />,
label: showPoolPanel ? 'Активный пул' : 'Адреса',
value: String(
(overview?.active_addresses ?? service.active_ips).length,
),
hint:
(overview?.active_addresses ?? service.active_ips).join(', ') ||
'нет',
},
]}
/>
<section
aria-label="Мониторинг"
className={
showPoolPanel
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
: 'grid min-w-0 items-start gap-2'
}
>
<ServiceHealthMonitor
items={logItems}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
{showPoolPanel ? (
<ServiceFailoverPanel
lbMode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
ipHealth={service.ip_health}
bindings={failoverBindings}
history={failoverHistory}
probes={logItems}
/>
) : null}
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
/>
) : (
<ServiceDetailGrid
service={service}
nodes={nodes}
probes={logItems}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) => {
setTogglingIp(ip)
toggleIpMutation.mutate({ ip, enabled })
}}
onChangeIp={(row: ServiceFqdnRow) =>
setChangeIp({
bindingId: row.binding_id,
ip: row.target_ips[0],
})
}
onChangeDomain={() => setChangeDomain(true)}
onAddNode={() => setAddNodeOpen(true)}
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
isLoading={nodesQuery.isLoading}
/>
)}
<ServiceEditSheet
mode="edit"
service={service}
groups={groups}
open={editOpen}
knownDomains={domainsQuery.data ?? []}
isSaving={saving}
isDeleting={deleteMutation.isPending}
onOpenChange={setEditOpen}
onSave={(_serviceId, body) => {
setSaving(true)
updateMutation.mutate({ body })
}}
onDelete={() => deleteMutation.mutate()}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={service.domains[0]?.domain_id ?? null}
/>
<FormSheet
open={addNodeOpen}
onOpenChange={setAddNodeOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={nodeForm}
onSubmit={(values) => createNodeMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</div>
)}
</QueryState>
)
return <ServiceAssetSection serviceId={serviceId} section="nodes" />
}
@@ -1,11 +1,12 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { ServiceAssetSection } from '@/components/services/service-asset-section'
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
component: ServiceNodesPage,
})
function ServiceNodesPage() {
const { serviceId } = Route.useParams()
return <ServiceAssetSection serviceId={serviceId} section="nodes" />
}
@@ -1,12 +1,42 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { ActivityIcon, GlobeIcon, NetworkIcon, ServerIcon } from 'lucide-react'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { EmptyState } from '@/components/empty-state'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { LoadingButton } from '@/components/loading-button'
import { QueryState } from '@/components/query-state'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import type { ServiceFqdnRow } from '@/components/services/service-detail-grid'
import { ServiceDetailActionsProvider } from '@/components/services/service-detail-context'
import { PageShell } from '@/components/page-shell'
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
import { api } from '@/lib/api-client'
import { hasSharedPool } from '@/lib/failover-events'
import { resolveServiceDisplayHealth } from '@/lib/health-log'
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import {
createServiceNode,
domainsListQueryOptions,
invalidateServiceDetail,
serviceFailoverLogQueryOptions,
serviceGroupsQueryOptions,
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { cn } from '@cfdm/ui/lib/utils'
export const Route = createFileRoute('/_auth/services/$serviceId')({
loader: async ({ context: { queryClient }, params }) => {
@@ -23,10 +53,336 @@ export const Route = createFileRoute('/_auth/services/$serviceId')({
component: ServiceLayout,
})
const SECTION_TABS = [
{ id: 'nodes', label: 'Ноды', to: '/services/$serviceId/nodes' },
{ id: 'health', label: 'Health', to: '/services/$serviceId/health' },
{ id: 'routing', label: 'Маршрутизация', to: '/services/$serviceId/routing' },
{ id: 'subdomains', label: 'Поддомены', to: '/services/$serviceId/subdomains' },
] as const
function activeSection(pathname: string, serviceId: string) {
const path = pathname.replace(/\/$/, '')
const base = `/services/${serviceId}`
if (path === `${base}/health`) return 'health'
if (path === `${base}/routing`) return 'routing'
if (path === `${base}/subdomains`) return 'subdomains'
return 'nodes'
}
/**
* Общая полоса вкладок карточки сервиса поверх существующих маршрутов.
* Preview: https://reui.io/preview/base/stats-12
* Preview: https://reui.io/preview/base/form-7
* Preview: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/frame
*/
function ServiceLayout() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const pathname = useRouterState({ select: (state) => state.location.pathname })
const section = activeSection(pathname, serviceId)
const viewQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions())
const service = viewQuery.data
const logItems = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items])
const nodeCount = Array.isArray(nodesQuery.data) ? nodesQuery.data.length : undefined
const displayHealth = useMemo(
() =>
resolveServiceDisplayHealth(
service?.health_status,
service?.ip_health ?? [],
logItems,
),
[service?.health_status, service?.ip_health, logItems],
)
const failoverBindings = useMemo(
() =>
(service?.domains ?? []).map((domain) => ({
fqdn: domain.fqdn,
configured: domain.target_ips,
active: domain.active_ips ?? [],
})),
[service?.domains],
)
const uniqueEnabledIps = useMemo(
() => (service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
[service?.ips, service?.ip_enabled],
)
const showPoolPanel =
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [changeIp, setChangeIp] = useState<{ bindingId: number; ip?: string } | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const nodeForm = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const groups = groupsQuery.data ? [...groupsQuery.data.groups] : []
const updateMutation = useMutation({
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: async () => {
await invalidateServiceDetail(queryClient, id)
setEditOpen(false)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => setSaving(false),
})
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/api/v1/services/${id}`),
onSuccess: async () => {
await invalidateServiceDetail(queryClient, id)
toast.success('Сервис удалён')
await navigate({ to: '/services' })
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
})
const disableMutation = useMutation({
mutationFn: () => api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled: false }),
onSuccess: async () => {
await invalidateServiceDetail(queryClient, id)
toast.success('Сервис выключен')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось выключить сервис')
},
})
const createNodeMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await invalidateServiceDetail(queryClient, id)
setAddNodeOpen(false)
nodeForm.reset()
},
onError: (err: unknown) =>
toast.error(err instanceof Error ? err.message : 'Не удалось добавить ноду'),
})
const counts: Record<(typeof SECTION_TABS)[number]['id'], number | undefined> = {
nodes: nodeCount,
health: undefined,
routing: service?.ips.length,
subdomains: service?.domains.length,
}
return (
<PageShell>
<Outlet />
<QueryState
isLoading={viewQuery.isLoading}
isError={viewQuery.isError}
error={viewQuery.error}
onRetry={() => {
void viewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : (
<ServiceDetailActionsProvider
value={{
openAddNode: () => setAddNodeOpen(true),
openChangeIp: (row: ServiceFqdnRow) =>
setChangeIp({ bindingId: row.binding_id, ip: row.target_ips[0] }),
openChangeDomain: () => setChangeDomain(true),
}}
>
<DetailPanel>
<DetailPanel.Header
title={service.name}
description="Ноды, проверки и маршрутизация"
actions={
<>
<Button type="button" variant="outline" onClick={() => setEditOpen(true)}>
Изменить
</Button>
{service.enabled ? (
<ConfirmDialog
title="Выключить сервис?"
description="Сервис будет выключен, связанные DNS-записи будут сняты."
confirmLabel="Выключить"
trigger={
<Button
type="button"
variant="outline"
disabled={disableMutation.isPending}
>
Выключить
</Button>
}
onConfirm={() => disableMutation.mutate()}
/>
) : null}
<Button type="button" onClick={() => setAddNodeOpen(true)}>
Добавить ноду
</Button>
</>
}
/>
<KpiStatGrid
cards={[
{
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: displayHealth === 'up' ? 'OK' : displayHealth,
variant:
displayHealth === 'down'
? 'destructive'
: displayHealth === 'degraded'
? 'warning'
: 'default',
iconClassName:
displayHealth === 'down'
? 'text-destructive'
: displayHealth === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
},
{
id: 'ip',
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: showPoolPanel
? `${service.active_ips.length} в пуле`
: uniqueEnabledIps.length <= 1
? 'без балансировки'
: service.ips.join(', ') || 'нет',
},
{
id: 'pool',
icon: <ServerIcon />,
label: showPoolPanel ? 'Активный пул' : 'Адреса',
value: String(service.active_ips.length),
hint: service.active_ips.join(', ') || 'нет',
},
]}
/>
<nav aria-label="Разделы сервиса" className="flex gap-5 overflow-x-auto">
{SECTION_TABS.map((tab) => {
const isActive = section === tab.id
const count = counts[tab.id]
return (
<Link
key={tab.id}
to={tab.to}
params={{ serviceId }}
aria-current={isActive ? 'page' : undefined}
className={cn(
'text-muted-foreground hover:text-foreground relative inline-flex h-auto shrink-0 items-center gap-2 px-0 pb-3 text-sm font-medium transition-colors',
'after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground after:opacity-0',
isActive && 'text-foreground after:opacity-100',
)}
>
<span>{tab.label}</span>
{count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{count}
</span>
) : null}
</Link>
)
})}
</nav>
<Outlet />
</DetailPanel>
<ServiceEditSheet
mode="edit"
service={service}
groups={groups}
open={editOpen}
knownDomains={domainsQuery.data ?? []}
isSaving={saving}
isDeleting={deleteMutation.isPending}
onOpenChange={setEditOpen}
onSave={(_serviceId, body) => {
setSaving(true)
updateMutation.mutate({ body })
}}
onDelete={() => deleteMutation.mutate()}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={service.domains[0]?.domain_id ?? null}
/>
<FormSheet
open={addNodeOpen}
onOpenChange={setAddNodeOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={nodeForm}
onSubmit={(values) => createNodeMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="node-address">
<Input
id="node-address"
{...nodeForm.register('address')}
placeholder="10.0.0.10"
/>
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="node-port" hint="Необязательно">
<Input id="node-port" {...nodeForm.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</ServiceDetailActionsProvider>
)}
</QueryState>
</PageShell>
)
}
@@ -1,11 +1,12 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { ServiceRoutingSection } from '@/components/services/service-routing-section'
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
component: ServiceRoutingPage,
})
function ServiceRoutingPage() {
const { serviceId } = Route.useParams()
return <ServiceRoutingSection serviceId={serviceId} />
}
@@ -1,11 +1,17 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { ServiceAssetSection } from '@/components/services/service-asset-section'
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
component: ServiceSubdomainsPage,
})
function ServiceSubdomainsPage() {
const { serviceId } = Route.useParams()
return (
<div className="flex flex-col gap-4">
<ServiceAssetSection serviceId={serviceId} section="fqdn" />
<ServiceAssetSection serviceId={serviceId} section="ssl" />
</div>
)
}
+95 -9
View File
@@ -12,6 +12,8 @@ import {
serviceKeys,
} from '@/queries'
import { api } from '@/lib/api-client'
import { formatDate, sqliteUtcToIso } from '@/lib/format'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type {
CreateServiceGroupInput,
CreateServiceWithConfigInput,
@@ -23,6 +25,7 @@ import type {
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ServiceKpiStats } from '@/components/service-kpi-stats'
import { IncidentAlert } from '@/components/incident-alert'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
@@ -223,6 +226,10 @@ function ServicesPage() {
() => flattenServices(data, domainId),
[data, domainId],
)
const downService = useMemo(
() => pickHeaviestDown(flattenServices(data)),
[data],
)
useEffect(() => {
if (serviceId == null || !data || editingService) return
@@ -707,7 +714,9 @@ function ServicesPage() {
<PageHeader title="Сервисы" description={pageDescription} />
<EmptyState
title="Не удалось загрузить сервисы"
description={error?.message}
description={
error?.message ?? 'Не удалось загрузить сервисы. Повторите запрос.'
}
action={
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
@@ -726,6 +735,18 @@ function ServicesPage() {
description={pageDescription}
actions={headerActions}
/>
{downService ? (
<IncidentAlert
title={downServiceTitle(downService)}
description={downServiceDescription(downService)}
onShow={() =>
navigate({
to: '/services/$serviceId',
params: { serviceId: String(downService.id) },
})
}
/>
) : null}
{view === 'board' ? (
<>
<ServicesBulkToolbar
@@ -738,16 +759,20 @@ function ServicesPage() {
{catalogRows.length === 0 && (data?.groups.length ?? 0) === 0 ? (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
title="Нет сервисов"
description={
filteredDomain
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
: 'Сначала создайте группу, затем добавьте сервисы.'
: 'Создайте сервис и добавьте поддомен с нодами.'
}
action={
<div className="flex flex-wrap justify-center gap-2">
{addMenu}
</div>
<Button
type="button"
variant="outline"
onClick={() => handleOpenCreateService()}
>
Создать сервис
</Button>
}
/>
) : (
@@ -800,9 +825,13 @@ function ServicesPage() {
onDeleteGroup={setDeletingGroup}
onAddServiceToGroup={handleOpenCreateService}
emptyAction={
<div className="flex flex-wrap justify-center gap-2">
{addMenu}
</div>
<Button
type="button"
variant="outline"
onClick={() => handleOpenCreateService()}
>
Создать сервис
</Button>
}
/>
</>
@@ -811,3 +840,60 @@ function ServicesPage() {
</PageShell>
)
}
function pickHeaviestDown(rows: ServiceCatalogRow[]): ServiceView | null {
const downs = rows
.map((row) => row.service)
.filter((service) => service.health_status === 'down')
if (downs.length === 0) return null
return downs.sort((left, right) => {
const weight = downWeight(right) - downWeight(left)
if (weight !== 0) return weight
return left.name.localeCompare(right.name, 'ru')
})[0]
}
function downWeight(service: ServiceView): number {
const rows = service.ip_health ?? []
const down = rows.filter((row) => row.status === 'down').length
const allDown = rows.length > 0 && down === rows.length
return (allDown ? 1000 : 0) + down
}
function downServiceTitle(service: ServiceView): string {
const rows = service.ip_health ?? []
const allDown =
rows.length > 0 && rows.every((row) => row.status === 'down')
return allDown
? `${service.name}: все ноды недоступны.`
: `${service.name} недоступен.`
}
function downServiceDescription(service: ServiceView): string {
const fqdn = serviceDisplayFqdns(service).find((value) => value && value !== '—')
const checkedAt = latestCheck(service)
const errorText = (service.ip_health ?? []).find(
(row) => row.status === 'down' && row.last_error,
)?.last_error
const parts = [
fqdn,
checkedAt ? `последняя проверка ${formatDate(checkedAt)}` : null,
errorText,
].filter((part): part is string => Boolean(part))
return parts.join(' · ') || 'Статус Down.'
}
function latestCheck(service: ServiceView): string | null {
let latestMs = Number.NEGATIVE_INFINITY
let latest: string | null = null
for (const row of service.ip_health ?? []) {
if (!row.last_checked_at) continue
const ms = new Date(
sqliteUtcToIso(row.last_checked_at) ?? row.last_checked_at,
).getTime()
if (Number.isNaN(ms) || ms <= latestMs) continue
latestMs = ms
latest = row.last_checked_at
}
return latest
}
+84 -23
View File
@@ -5,9 +5,10 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
import { HeartPulseIcon, GlobeIcon, TriangleAlertIcon } from 'lucide-react'
import { api } from '@/lib/api-client'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { SettingRow } from '@/components/setting-row'
import { LoadingButton } from '@/components/loading-button'
import {
@@ -57,6 +58,33 @@ type FormValues = z.infer<typeof formSchema>
type GlobalpingValues = z.infer<typeof globalpingSchema>
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
function toHealthFormValues(data: {
healthCheckCron: string
healthDegradedFailures: number
healthDownFailures: number
healthLatencyWarnMs: number
healthSuccessRecoveries: number
}): FormValues {
return {
healthCheckCron: data.healthCheckCron,
healthDegradedFailures: data.healthDegradedFailures,
healthDownFailures: data.healthDownFailures,
healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries,
}
}
function toGlobalpingFormValues(data: {
globalpingLocations?: string
globalpingLimit?: number
}): GlobalpingValues {
return {
globalpingToken: '',
globalpingLocations: data.globalpingLocations || 'World',
globalpingLimit: data.globalpingLimit ?? 3,
}
}
type SettingsResponse = FormValues & {
id: string
healthWorkerUrl?: string
@@ -161,18 +189,8 @@ function HealthSettingsPage() {
useEffect(() => {
if (!data) return
form.reset({
healthCheckCron: data.healthCheckCron,
healthDegradedFailures: data.healthDegradedFailures,
healthDownFailures: data.healthDownFailures,
healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries,
})
gpForm.reset({
globalpingToken: '',
globalpingLocations: data.globalpingLocations || 'World',
globalpingLimit: data.globalpingLimit ?? 3,
})
form.reset(toHealthFormValues(data))
gpForm.reset(toGlobalpingFormValues(data))
}, [data, form, gpForm])
const saveMut = useMutation({
@@ -184,7 +202,8 @@ function HealthSettingsPage() {
healthLatencyWarnMs: values.healthLatencyWarnMs,
healthSuccessRecoveries: values.healthSuccessRecoveries,
}),
onSuccess: () => {
onSuccess: (saved) => {
form.reset(toHealthFormValues(saved))
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки health-check сохранены')
},
@@ -201,13 +220,10 @@ function HealthSettingsPage() {
? { globalpingToken: values.globalpingToken.trim() }
: {}),
}),
onSuccess: () => {
onSuccess: (saved) => {
gpForm.reset(toGlobalpingFormValues(saved))
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки Globalping сохранены')
gpForm.reset({
...gpForm.getValues(),
globalpingToken: '',
})
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
@@ -224,8 +240,21 @@ function HealthSettingsPage() {
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
})
const healthDirty = Boolean(data) && form.formState.isDirty
const globalpingDirty = Boolean(data) && gpForm.formState.isDirty
return (
<div className="flex w-full flex-col gap-4">
{healthDirty || globalpingDirty ? (
<Alert variant="warning" role="status">
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>Есть несохранённые изменения</AlertTitle>
<AlertDescription>
Поля health-check или Globalping отличаются от последних сохранённых
значений. Сохраните их или сбросьте к данным с сервера.
</AlertDescription>
</Alert>
) : null}
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
@@ -360,11 +389,27 @@ function HealthSettingsPage() {
/>
</SettingRow>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<FrameFooter className="flex flex-row justify-end gap-2">
<ConfirmDialog
title="Сбросить изменения?"
description="Поля health-check вернутся к последним сохранённым значениям с сервера. Несохранённые правки будут отброшены."
confirmLabel="Сбросить"
disabled={!healthDirty || saveMut.isPending}
onConfirm={() => form.reset()}
trigger={
<Button
type="button"
variant="outline"
disabled={!healthDirty || saveMut.isPending}
>
Сбросить
</Button>
}
/>
<LoadingButton
type="submit"
isLoading={saveMut.isPending}
disabled={isLoading || !form.formState.isDirty}
disabled={isLoading || !healthDirty}
>
Сохранить
</LoadingButton>
@@ -571,11 +616,27 @@ function HealthSettingsPage() {
/>
</SettingRow>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<FrameFooter className="flex flex-row justify-end gap-2">
<ConfirmDialog
title="Сбросить изменения?"
description="Локации, число проб и поле токена вернутся к последним сохранённым значениям. Введённый новый токен будет очищен."
confirmLabel="Сбросить"
disabled={!globalpingDirty || saveGpMut.isPending}
onConfirm={() => gpForm.reset()}
trigger={
<Button
type="button"
variant="outline"
disabled={!globalpingDirty || saveGpMut.isPending}
>
Сбросить
</Button>
}
/>
<LoadingButton
type="submit"
isLoading={saveGpMut.isPending}
disabled={isLoading || !gpForm.formState.isDirty}
disabled={isLoading || !globalpingDirty}
>
Сохранить
</LoadingButton>
+7 -1
View File
@@ -10074,6 +10074,11 @@ type ServiceIpHealthRow = {
};
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
declare function listIpHealthByServiceIds(db: Db, serviceIds: number[]): Map<number, ServiceIpHealthRow[]>;
/**
* Latest probe per service+IP+provider from health_probe_log, then any-up per IP.
* Display overlay — hysteresis in ip_health_status is unchanged (DNS).
*/
declare function listLatestLiveHealthByServiceIds(db: Db, serviceIds: number[]): Map<number, ServiceIpHealthRow[]>;
declare function mergeHealthAggregates(parts: Array<HealthAggregate | undefined | null>): HealthAggregate;
declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null;
declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number, extras?: {
@@ -10298,6 +10303,7 @@ declare const repos_listHealthChecks: typeof listHealthChecks;
declare const repos_listHealthProbeLogForService: typeof listHealthProbeLogForService;
declare const repos_listIpHealthByServiceIds: typeof listIpHealthByServiceIds;
declare const repos_listIpHealthStatus: typeof listIpHealthStatus;
declare const repos_listLatestLiveHealthByServiceIds: typeof listLatestLiveHealthByServiceIds;
declare const repos_listNodes: typeof listNodes;
declare const repos_listNotificationLog: typeof listNotificationLog;
declare const repos_listOriginIpsForFqdn: typeof listOriginIpsForFqdn;
@@ -10345,7 +10351,7 @@ declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck;
declare const repos_upsertIpHealthStatus: typeof upsertIpHealthStatus;
declare const repos_upsertSubdomain: typeof upsertSubdomain;
declare namespace repos {
export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIpRows as listServiceIpRows, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDLine truncated
export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listLatestLiveHealthByServiceIds as listLatestLiveHealthByServiceIds, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIpRows as listServiceIpRows, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroLine truncated
}
export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, type HealthEngineFallbacks, type HealthEngineSettings, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, bindingNodes, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, failoverLog, getAppSettings, getAppSettingsSecrets, groups, healthCheck, healthChecks, healthProbeLog, ipHealthStatus, listAudit, nodes, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings };
+78
View File
@@ -757,6 +757,7 @@ __export(repos_exports, {
listHealthProbeLogForService: () => listHealthProbeLogForService,
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
listIpHealthStatus: () => listIpHealthStatus,
listLatestLiveHealthByServiceIds: () => listLatestLiveHealthByServiceIds,
listNodes: () => listNodes,
listNotificationLog: () => listNotificationLog,
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
@@ -2127,6 +2128,83 @@ function listIpHealthByServiceIds(db, serviceIds) {
}
return result;
}
function parseIpHealthState(status) {
if (status === "up" || status === "down" || status === "degraded" || status === "unknown") {
return status;
}
return void 0;
}
function bestAliveIpState(statuses) {
if (statuses.some((status) => status === "up")) return "up";
if (statuses.some((status) => status === "degraded")) return "degraded";
if (statuses.some((status) => status === "down")) return "down";
return "unknown";
}
function listLatestLiveHealthByServiceIds(db, serviceIds) {
const result = /* @__PURE__ */ new Map();
if (serviceIds.length === 0) return result;
const idList = sql2.join(
serviceIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT sb.service_id AS service_id,
l.ip AS ip,
l.provider AS provider,
l.status AS status,
l.latency_ms AS latency_ms,
l.error AS last_error,
l.colo AS colo,
l.checked_at AS checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
INNER JOIN (
SELECT sb2.service_id AS service_id,
l2.ip AS ip,
l2.provider AS provider,
MAX(l2.id) AS max_id
FROM health_probe_log l2
INNER JOIN service_bindings sb2
ON l2.scope = 'binding' AND l2.ref_id = sb2.id
WHERE sb2.service_id IN (${idList})
GROUP BY sb2.service_id, l2.ip, l2.provider
) latest
ON latest.max_id = l.id
`);
const byServiceIp = /* @__PURE__ */ new Map();
for (const row of rows) {
const status = parseIpHealthState(row.status);
if (!status) continue;
const key = `${row.service_id}\0${row.ip}`;
const probe = {
ip: row.ip,
status,
latency_ms: row.latency_ms,
last_checked_at: row.checked_at,
last_error: row.last_error,
provider: normalizeStatusProvider(row.provider),
colo: row.colo
};
const bucket = byServiceIp.get(key);
if (bucket) bucket.probes.push(probe);
else {
byServiceIp.set(key, {
serviceId: row.service_id,
ip: row.ip,
probes: [probe]
});
}
}
for (const { serviceId, ip, probes } of byServiceIp.values()) {
const status = bestAliveIpState(probes.map((probe) => probe.status));
const preferred = probes.find((probe) => probe.status === status) ?? probes[0];
const list = result.get(serviceId) ?? [];
list.push({ ...preferred, ip, status });
result.set(serviceId, list);
}
return result;
}
function mergeHealthAggregates(parts) {
const rank = {
unknown: 0,
+1
View File
@@ -27,6 +27,7 @@
"@radix-ui/react-tabs": "^1.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.18.0",
"next-themes": "^0.4.6",
+6 -2
View File
@@ -143,12 +143,16 @@ function AlertDialogDescription({
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof Button>) {
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button
<AlertDialogPrimitive.Close
data-slot="alert-dialog-action"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
+3
View File
@@ -319,6 +319,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: 1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
date-fns:
specifier: ^4.4.0
version: 4.4.0