feat(api, web): enhance health check and domain management features
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m3s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

- Integrated domain monitoring routes and bulk update functionality for domains in the API.
- Improved health check service to include domain monitoring and logging of health status changes.
- Updated web components to reflect health status with new HealthCheckBadge and enhanced domain filtering options.
- Refactored domain service to support bulk updates and improved domain management capabilities.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 02:25:12 +07:00
co-authored by Cursor
parent dd167a4fec
commit 64585ccd47
38 changed files with 4199 additions and 677 deletions
+38 -7
View File
@@ -7,6 +7,7 @@ import {
} from "@fastify/type-provider-zod"; } from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js"; import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { repos } from "@cfdm/db";
import authPlugin from "./plugins/auth.js"; import authPlugin from "./plugins/auth.js";
import cfClientPlugin from "./plugins/cf-client.js"; import cfClientPlugin from "./plugins/cf-client.js";
import { requireAuth } from "./plugins/auth.js"; import { requireAuth } from "./plugins/auth.js";
@@ -24,6 +25,10 @@ import { subdomainRoutes } from "./routes/subdomains.js";
import { certificateRoutes } from "./routes/certificates.js"; import { certificateRoutes } from "./routes/certificates.js";
import { syncRoutes } from "./routes/sync.js"; import { syncRoutes } from "./routes/sync.js";
import { healthCheckRoutes } from "./routes/health-check.js"; import { healthCheckRoutes } from "./routes/health-check.js";
import {
domainMonitorRoutes,
notificationRoutes,
} from "./routes/domain-monitors.js";
import { settingsRoutes } from "./routes/settings.js"; import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js"; import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import * as certificateService from "./services/certificate-service.js"; import * as certificateService from "./services/certificate-service.js";
@@ -75,6 +80,8 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await protectedApi.register(certificateRoutes); await protectedApi.register(certificateRoutes);
await protectedApi.register(syncRoutes); await protectedApi.register(syncRoutes);
await protectedApi.register(healthCheckRoutes); await protectedApi.register(healthCheckRoutes);
await protectedApi.register(domainMonitorRoutes);
await protectedApi.register(notificationRoutes);
await protectedApi.register(settingsRoutes); await protectedApi.register(settingsRoutes);
}, },
{ prefix: "/api/v1" }, { prefix: "/api/v1" },
@@ -117,14 +124,31 @@ export async function buildApp(opts: BuildAppOptions = {}) {
const healthTask = new AsyncTask( const healthTask = new AsyncTask(
"health-check", "health-check",
async () => { async () => {
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
};
const n = await healthCheckService.runAllChecks(app.db, { const n = await healthCheckService.runAllChecks(app.db, {
thresholds: { thresholds,
degradedFailures: config.healthDegradedFailures, onStatusChange: async (target, prev, next) => {
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
},
onStatusChange: async (target, _prev, _next) => {
try { try {
const label =
next === "up"
? "OK"
: next === "degraded"
? "Slow"
: next === "down"
? "Down"
: "—";
repos.insertNotificationLog(
app.db,
"ip_health",
target.scope,
target.ref_id,
`${target.hostname || target.ip}: ${label}`,
`IP ${target.ip}: ${prev ?? "—"}${label}`,
);
await serviceConfigService.reconcileDnsForTarget( await serviceConfigService.reconcileDnsForTarget(
app.db, app.db,
app.cf, app.cf,
@@ -139,7 +163,14 @@ export async function buildApp(opts: BuildAppOptions = {}) {
} }
}, },
}); });
app.log.info({ checked: n }, "health check completed"); const monitors = await healthCheckService.runDomainMonitors(
app.db,
thresholds,
);
app.log.info(
{ checked: n, monitors },
"health check completed",
);
}, },
(err) => { (err) => {
app.log.warn({ err }, "health check failed"); app.log.warn({ err }, "health check failed");
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyInstance } from "fastify";
import { createDomainMonitorSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
export async function domainMonitorRoutes(app: FastifyInstance) {
app.get("/domains/:id/monitors", async (request) => {
const { id } = request.params as { id: string };
return repos.listDomainMonitors(request.server.db, Number(id));
});
app.post("/domains/:id/monitors", async (request) => {
const { id } = request.params as { id: string };
const body = createDomainMonitorSchema.parse(request.body);
return repos.createDomainMonitor(request.server.db, Number(id), body);
});
app.delete("/domains/:domainId/monitors/:monitorId", async (request) => {
const { monitorId } = request.params as {
domainId: string;
monitorId: string;
};
repos.deleteDomainMonitor(request.server.db, Number(monitorId));
return { deleted: true };
});
app.get("/domains/:id/monitor-results", async (request) => {
const { id } = request.params as { id: string };
const query = request.query as { limit?: string };
const limit = query.limit ? Number(query.limit) : 50;
return repos.listDomainMonitorResultsForDomain(
request.server.db,
Number(id),
limit,
);
});
app.post("/domains/:id/monitors/run", async (request) => {
const config = request.server.config;
const checked = await healthCheckService.runDomainMonitors(request.server.db, {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
});
return { checked };
});
}
export async function notificationRoutes(app: FastifyInstance) {
app.get("/notifications/log", async (request) => {
const query = request.query as { limit?: string };
const limit = query.limit ? Number(query.limit) : 50;
return repos.listNotificationLog(request.server.db, limit);
});
}
+16 -9
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { updateDomainSchema } from "@cfdm/shared"; import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared";
import { z } from "zod"; import { z } from "zod";
import * as domainService from "../services/domain-service.js"; import * as domainService from "../services/domain-service.js";
@@ -25,6 +25,20 @@ export async function domainRoutes(app: FastifyInstance) {
); );
}); });
app.post("/domains/bulk", async (request) => {
const body = bulkUpdateDomainsSchema.parse(request.body);
const updated = domainService.bulkUpdateDomains(
request.server.db,
body.ids,
{
group_id: body.group_id,
environment: body.environment,
tags_add: body.tags_add,
},
);
return { updated };
});
app.get("/domains/:id", async (request) => { app.get("/domains/:id", async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
return domainService.getDomain(request.server.db, Number(id)); return domainService.getDomain(request.server.db, Number(id));
@@ -33,14 +47,7 @@ export async function domainRoutes(app: FastifyInstance) {
app.patch("/domains/:id", async (request) => { app.patch("/domains/:id", async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const body = updateDomainSchema.parse(request.body); const body = updateDomainSchema.parse(request.body);
const existing = domainService.getDomain(request.server.db, Number(id)); return domainService.updateDomain(request.server.db, Number(id), body);
return domainService.updateDomain(
request.server.db,
Number(id),
body.group_id !== undefined ? body.group_id : existing.group_id,
body.status ?? existing.status,
body.cert_monitoring,
);
}); });
app.delete("/domains/:id", async (request) => { app.delete("/domains/:id", async (request) => {
+30 -8
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { healthStatusQuerySchema } from "@cfdm/shared"; import { healthStatusQuerySchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js"; import * as healthCheckService from "../services/health-check-service.js";
import * as serviceConfigService from "../services/service-config-service.js"; import * as serviceConfigService from "../services/service-config-service.js";
@@ -15,14 +16,31 @@ export async function healthCheckRoutes(app: FastifyInstance) {
app.post("/health-check/run", async (request) => { app.post("/health-check/run", async (request) => {
const config = request.server.config; const config = request.server.config;
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
};
const checked = await healthCheckService.runAllChecks(request.server.db, { const checked = await healthCheckService.runAllChecks(request.server.db, {
thresholds: { thresholds,
degradedFailures: config.healthDegradedFailures, onStatusChange: async (target, prev, next) => {
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
},
onStatusChange: async (target, _prev, _next) => {
try { try {
const label =
next === "up"
? "OK"
: next === "degraded"
? "Slow"
: next === "down"
? "Down"
: "—";
repos.insertNotificationLog(
request.server.db,
"ip_health",
target.scope,
target.ref_id,
`${target.hostname || target.ip}: ${label}`,
`IP ${target.ip}: ${prev ?? "—"}${label}`,
);
await serviceConfigService.reconcileDnsForTarget( await serviceConfigService.reconcileDnsForTarget(
request.server.db, request.server.db,
request.server.cf, request.server.cf,
@@ -30,10 +48,14 @@ export async function healthCheckRoutes(app: FastifyInstance) {
target.ref_id, target.ref_id,
); );
} catch { } catch {
// best-effort reconcile; ошибки логируются cron-задачей // best-effort
} }
}, },
}); });
return { checked }; const monitors = await healthCheckService.runDomainMonitors(
request.server.db,
thresholds,
);
return { checked, monitors };
}); });
} }
+44 -4
View File
@@ -43,11 +43,51 @@ export async function createDomain(
export function updateDomain( export function updateDomain(
db: Db, db: Db,
id: number, id: number,
groupId: number | null, patch: {
status: string, group_id?: number | null;
certMonitoring?: string, status?: string;
cert_monitoring?: string;
environment?: string | null;
tags?: string[];
},
): Domain { ): Domain {
return repos.updateDomain(db, id, groupId, status, certMonitoring); const domain = repos.updateDomain(db, id, {
group_id: patch.group_id,
status: patch.status,
cert_monitoring: patch.cert_monitoring,
environment: patch.environment,
});
if (patch.tags !== undefined) {
repos.setDomainTags(db, id, patch.tags);
}
return domain;
}
export function bulkUpdateDomains(
db: Db,
ids: number[],
patch: {
group_id?: number | null;
environment?: string | null;
tags_add?: string[];
},
): number {
let updated = 0;
for (const id of ids) {
try {
repos.updateDomain(db, id, {
group_id: patch.group_id,
environment: patch.environment,
});
if (patch.tags_add?.length) {
repos.addDomainTags(db, id, patch.tags_add);
}
updated += 1;
} catch {
// skip missing
}
}
return updated;
} }
export function deleteDomain(db: Db, id: number): void { export function deleteDomain(db: Db, id: number): void {
+121 -4
View File
@@ -1,4 +1,5 @@
import { connect } from "node:net"; import { connect } from "node:net";
import { resolve4, resolve6 } from "node:dns/promises";
import { Agent, fetch as undiciFetch } from "undici"; import { Agent, fetch as undiciFetch } from "undici";
import type { Db } from "@cfdm/db"; import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
@@ -68,10 +69,6 @@ async function httpProbe(
const pathWithSlash = path.startsWith("/") ? path : `/${path}`; const pathWithSlash = path.startsWith("/") ? path : `/${path}`;
const port = target.port ?? 80; const port = target.port ?? 80;
const useTls = port === 443; const useTls = port === 443;
// For HTTPS on 443, probe via the hostname (URL host = hostname) so TLS SNI, Host header
// and any edge/vhost protection (e.g. Cloudflare origin 421 on direct-IP) all line up.
// For multi-A records this loses strict per-IP HTTPS granularity — use TCP probe for that.
// For plain HTTP we still hit the literal IP (per-record target).
const urlHost = useTls ? target.hostname || ip : ip; const urlHost = useTls ? target.hostname || ip : ip;
const url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`; const url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`;
const dispatcher = const dispatcher =
@@ -119,6 +116,47 @@ async function httpProbe(
} }
} }
/** Host reachability via TCP :443 then :80 (ICMP often unavailable in Node). */
async function pingProbe(hostname: string, timeoutMs: number): Promise<ProbeResult> {
const ports = [443, 80];
let last: ProbeResult = {
ok: false,
latencyMs: 0,
error: "unreachable",
};
for (const port of ports) {
last = await tcpProbe(hostname, port, timeoutMs);
if (last.ok) return last;
}
return last;
}
async function dnsProbe(hostname: string): Promise<ProbeResult> {
const started = Date.now();
try {
const [v4, v6] = await Promise.allSettled([
resolve4(hostname),
resolve6(hostname),
]);
const hasV4 = v4.status === "fulfilled" && v4.value.length > 0;
const hasV6 = v6.status === "fulfilled" && v6.value.length > 0;
if (!hasV4 && !hasV6) {
return {
ok: false,
latencyMs: Date.now() - started,
error: "no A/AAAA records",
};
}
return { ok: true, latencyMs: Date.now() - started, error: null };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - started,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function probeTarget( export async function probeTarget(
target: HealthCheckTarget, target: HealthCheckTarget,
): Promise<ProbeResult> { ): Promise<ProbeResult> {
@@ -127,6 +165,12 @@ export async function probeTarget(
if (target.type === "http") { if (target.type === "http") {
return httpProbe(target.ip, target, timeoutMs); return httpProbe(target.ip, target, timeoutMs);
} }
if (target.type === "ping") {
return pingProbe(target.hostname || target.ip, timeoutMs);
}
if (target.type === "dns") {
return dnsProbe(target.hostname || target.ip);
}
return tcpProbe(target.ip, port, timeoutMs); return tcpProbe(target.ip, port, timeoutMs);
} }
@@ -205,6 +249,79 @@ export async function runAllChecks(
return targets.length; return targets.length;
} }
export async function runDomainMonitors(
db: Db,
thresholds: HealthCheckThresholds,
): Promise<number> {
const monitors = repos.listEnabledDomainMonitors(db);
let checked = 0;
for (const monitor of monitors) {
const target: HealthCheckTarget = {
scope: "binding",
ref_id: monitor.id,
ip: monitor.hostname,
hostname: monitor.hostname,
type: monitor.type as HealthCheckTarget["type"],
port: monitor.type === "http" ? (monitor.path?.includes("443") ? 443 : 80) : null,
path: monitor.path,
expected_status: monitor.expected_status,
timeout_ms: monitor.timeout_ms,
};
let result: ProbeResult;
if (monitor.type === "http") {
result = await httpProbe(monitor.hostname, {
...target,
port: 443,
path: monitor.path ?? "/",
}, monitor.timeout_ms);
if (!result.ok) {
result = await httpProbe(monitor.hostname, {
...target,
port: 80,
path: monitor.path ?? "/",
}, monitor.timeout_ms);
}
} else if (monitor.type === "ping") {
result = await pingProbe(monitor.hostname, monitor.timeout_ms);
} else {
result = await dnsProbe(monitor.hostname);
}
const prevStatus = monitor.last_status as IpHealthState;
const { state } = deriveState(
result.ok,
result.latencyMs,
{
consecutive_failures: result.ok ? 0 : 1,
status: prevStatus,
},
thresholds,
);
repos.updateDomainMonitorResult(
db,
monitor.id,
state,
result.latencyMs,
result.error,
);
if (prevStatus !== state && prevStatus !== "unknown") {
const label =
state === "up" ? "OK" : state === "degraded" ? "Slow" : state === "down" ? "Down" : "—";
repos.insertNotificationLog(
db,
"domain_monitor",
"domain_monitor",
monitor.id,
`${monitor.hostname}: ${label}`,
result.error
? `${monitor.type.toUpperCase()}${label}. ${result.error}`
: `${monitor.type.toUpperCase()}${label}${result.latencyMs != null ? ` (${result.latencyMs} мс)` : ""}`,
);
}
checked += 1;
}
return checked;
}
export function listStatus( export function listStatus(
db: Db, db: Db,
scope: "binding" | "group", scope: "binding" | "group",
+15 -21
View File
@@ -141,13 +141,11 @@ describe("certificates", () => {
"required.example.com", "required.example.com",
"cf-zone-req", "cf-zone-req",
); );
repos.updateDomain( repos.updateDomain(testApp.db, domain.id, {
testApp.db, group_id: null,
domain.id, status: "active",
null, cert_monitoring: CERT_MONITOR_REQUIRED,
"active", });
CERT_MONITOR_REQUIRED,
);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
@@ -191,13 +189,11 @@ describe("certificates", () => {
CERT_ERROR, CERT_ERROR,
"stale", "stale",
); );
repos.updateDomain( repos.updateDomain(testApp.db, domain.id, {
testApp.db, group_id: null,
domain.id, status: "active",
null, cert_monitoring: CERT_MONITOR_SKIPPED,
"active", });
CERT_MONITOR_SKIPPED,
);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null, expiresAt: null,
@@ -229,13 +225,11 @@ describe("certificates", () => {
"broken.example.com", "broken.example.com",
"cf-zone-broken", "cf-zone-broken",
); );
repos.updateDomain( repos.updateDomain(testApp.db, domain.id, {
testApp.db, group_id: null,
domain.id, status: "active",
null, cert_monitoring: CERT_MONITOR_REQUIRED,
"active", });
CERT_MONITOR_REQUIRED,
);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null, expiresAt: null,
@@ -5,9 +5,15 @@ import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters' import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import type { DomainListItem } from '@/lib/schemas' import type { DomainListItem } from '@/lib/schemas'
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils' import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { import {
@@ -19,12 +25,18 @@ import {
export const DOMAIN_TABS = [ export const DOMAIN_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'with_group', label: 'С группой' }, { id: 'ok', label: 'OK' },
{ id: 'slow', label: 'Slow' },
{ id: 'down', label: 'Down' },
{ id: 'unknown', label: '—' },
{ id: 'without_group', label: 'Без группы' }, { id: 'without_group', label: 'Без группы' },
] as const ] as const
export function domainTabFilter(item: DomainListItem, tabId: string) { export function domainTabFilter(item: DomainListItem, tabId: string) {
if (tabId === 'with_group') return item.group_id != null if (tabId === 'ok') return item.health_status === 'up'
if (tabId === 'slow') return item.health_status === 'degraded'
if (tabId === 'down') return item.health_status === 'down'
if (tabId === 'unknown') return item.health_status === 'unknown'
if (tabId === 'without_group') return item.group_id == null if (tabId === 'without_group') return item.group_id == null
return true return true
} }
@@ -56,6 +68,29 @@ export function useDomainFilterFields(
customValueRenderer: (values) => customValueRenderer: (values) =>
renderSingleSelectedLabel(values, groupOptions), renderSingleSelectedLabel(values, groupOptions),
}, },
{
key: 'health_status',
label: 'Доступность',
type: 'select',
className: 'w-[140px]',
options: [
{ label: 'OK', value: 'up' },
{ label: 'Slow', value: 'degraded' },
{ label: 'Down', value: 'down' },
{ label: '—', value: 'unknown' },
],
},
{
key: 'environment',
label: 'Env',
type: 'select',
className: 'w-[120px]',
options: [
{ label: 'prod', value: 'prod' },
{ label: 'staging', value: 'staging' },
{ label: 'dev', value: 'dev' },
],
},
], ],
[groupOptions], [groupOptions],
) )
@@ -64,123 +99,63 @@ export function useDomainFilterFields(
export function domainFilterFieldValue(item: DomainListItem, field: string) { export function domainFilterFieldValue(item: DomainListItem, field: string) {
switch (field) { switch (field) {
case 'zone_name': case 'zone_name':
return `${item.zone_name} ${item.group_name ?? ''}`.toLowerCase() return `${item.zone_name} ${item.group_name ?? ''} ${(item.tags ?? []).join(' ')}`.toLowerCase()
case 'group_id': case 'group_id':
return item.group_id != null ? String(item.group_id) : 'none' return item.group_id != null ? String(item.group_id) : 'none'
case 'health_status':
return item.health_status ?? 'unknown'
case 'environment':
return item.environment ?? ''
default: default:
return '' return ''
} }
} }
function EnvChip({ env }: { env: string | null | undefined }) {
if (!env) return null
return (
<Badge variant="outline" size="xs" className="font-mono">
{env}
</Badge>
)
}
export function useDomainColumns({ export function useDomainColumns({
onRequestDelete, onRequestDelete,
isDeleting, isDeleting,
enableSelection = false,
}: { }: {
onRequestDelete: (domain: DomainListItem) => void onRequestDelete: (domain: DomainListItem) => void
isDeleting?: boolean isDeleting?: boolean
enableSelection?: boolean
}) { }) {
const columns = useMemo<ColumnDef<DomainListItem>[]>( const columns = useMemo<ColumnDef<DomainListItem>[]>(
() => [ () => {
{ const cols: ColumnDef<DomainListItem>[] = []
id: 'zone_name', if (enableSelection) {
accessorKey: 'zone_name', cols.push({
header: ({ column }) => ( id: 'select',
<DataGridColumnHeader column={column} title="Зона" /> header: () => <DataGridTableRowSelectAll />,
), cell: ({ row }) => <DataGridTableRowSelect row={row} />,
cell: ({ row }) => ( size: 36,
<Button enableSorting: false,
variant="link" enableHiding: false,
className="h-auto p-0 font-medium" })
nativeButton={false} }
render={ cols.push(
<Link {
to="/domains/$domainId" id: 'zone_name',
params={{ domainId: String(row.original.id) }} accessorKey: 'zone_name',
/> header: ({ column }) => (
} <DataGridColumnHeader column={column} title="Зона" />
> ),
{row.original.zone_name} cell: ({ row }) => (
</Button> <div className="flex min-w-0 flex-col gap-1">
), <div className="flex min-w-0 flex-wrap items-center gap-1.5">
}, <Button
{ variant="link"
id: 'group', className="h-auto max-w-full truncate p-0 font-medium"
header: ({ column }) => ( nativeButton={false}
<DataGridColumnHeader column={column} title="Группа" />
),
cell: ({ row }) => {
const domain = row.original
if (domain.group_id && domain.group_name) {
return (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
{domain.group_name}
</Button>
)
}
return <Badge variant="outline">Без группы</Badge>
},
},
{
id: 'service_count',
accessorKey: 'service_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={
<Link to="/services" search={{ domainId: row.original.id }} />
}
>
{row.original.service_count}
</Button>
),
},
{
id: 'status',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'last_synced_at',
accessorKey: 'last_synced_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Синхронизация" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.last_synced_at ?? '—'}
</span>
),
},
{
id: 'actions',
header: '',
enableHiding: false,
cell: ({ row }) => (
<div className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon" className="size-8" />}
>
<MoreHorizontalIcon />
<span className="sr-only">Действия</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={ render={
<Link <Link
to="/domains/$domainId" to="/domains/$domainId"
@@ -188,33 +163,155 @@ export function useDomainColumns({
/> />
} }
> >
Обзор {row.original.zone_name}
</DropdownMenuItem> </Button>
<DropdownMenuItem <EnvChip env={row.original.environment} />
</div>
{(row.original.tags ?? []).length > 0 ? (
<div className="flex flex-wrap gap-1">
{row.original.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" size="xs">
{tag}
</Badge>
))}
</div>
) : null}
</div>
),
},
{
id: 'health',
accessorKey: 'health_status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Доступность" />
),
cell: ({ row }) => (
<HealthCheckBadge
status={row.original.health_status ?? 'unknown'}
latencyMs={row.original.health_latency_ms}
showLatency
/>
),
},
{
id: 'group',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Группа" />
),
cell: ({ row }) => {
const domain = row.original
if (domain.group_id && domain.group_name) {
return (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={ render={
<Link <Link
to="/domains/$domainId/dns" to="/groups/$groupId"
params={{ domainId: String(row.original.id) }} params={{ groupId: String(domain.group_id) }}
search={{ host: undefined }}
/> />
} }
> >
DNS {domain.group_name}
</DropdownMenuItem> </Button>
<DropdownMenuItem )
variant="destructive" }
disabled={isDeleting} return <Badge variant="outline">Без группы</Badge>
onClick={() => onRequestDelete(row.original)} },
},
{
id: 'service_count',
accessorKey: 'service_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={
<Link to="/services" search={{ domainId: row.original.id }} />
}
>
{row.original.service_count}
</Button>
),
},
{
id: 'status',
header: 'Зона CF',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'last_synced_at',
accessorKey: 'last_synced_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Синхронизация" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatRelative(
sqliteUtcToIso(row.original.last_synced_at) ??
row.original.last_synced_at,
)}
</span>
),
},
{
id: 'actions',
header: '',
enableHiding: false,
cell: ({ row }) => (
<div className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon" className="size-8" />
}
> >
Удалить <MoreHorizontalIcon />
</DropdownMenuItem> <span className="sr-only">Действия</span>
</DropdownMenuContent> </DropdownMenuTrigger>
</DropdownMenu> <DropdownMenuContent align="end">
</div> <DropdownMenuItem
), render={
}, <Link
], to="/domains/$domainId"
[isDeleting, onRequestDelete], params={{ domainId: String(row.original.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId: String(row.original.id) }}
search={{ host: undefined }}
/>
}
>
DNS
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDeleting}
onClick={() => onRequestDelete(row.original)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
)
return cols
},
[enableSelection, isDeleting, onRequestDelete],
) )
return { columns } return { columns }
+48 -111
View File
@@ -1,11 +1,10 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { Link2Icon } from 'lucide-react' import { Link2Icon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { StatusBadge } from '@/components/status-badge' import { HealthCheckBadge } from '@/components/health-check-badge'
import { groupBindingsByHostname } from '@/lib/domain-ips' import { groupBindingsByHostname } from '@/lib/domain-ips'
import { useHealthRows } from '@/lib/use-aggregated-health' import { useHealthRows } from '@/lib/use-aggregated-health'
import { formatDate } from '@/lib/format' import type { ServiceBinding } from '@/lib/schemas'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
import { AppBadge } from '@/components/app-badge' import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button' import { AppButton } from '@/components/app-button'
import { import {
@@ -24,57 +23,11 @@ import {
AppItemSeparator, AppItemSeparator,
AppItemTitle, AppItemTitle,
} from '@/components/app-item' } from '@/components/app-item'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
interface DomainBindingsCardProps { interface DomainBindingsCardProps {
bindings: ServiceBinding[] bindings: ServiceBinding[]
} }
const healthDotClass: Record<IpHealthStatus['status'], string> = {
up: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
unknown: 'bg-muted-foreground/40',
}
const healthLabel: Record<IpHealthStatus['status'], string> = {
up: 'OK',
degraded: 'Деград.',
down: 'Down',
unknown: '—',
}
function IpHealthDot({ health }: { health: IpHealthStatus }) {
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
'inline-flex size-2 shrink-0 cursor-default rounded-full',
healthDotClass[health.status],
)}
aria-label={`Health: ${healthLabel[health.status]}`}
/>
}
/>
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function HostnameIpsHealth({ function HostnameIpsHealth({
bindings, bindings,
ips, ips,
@@ -95,9 +48,18 @@ function HostnameIpsHealth({
{ips.map((ip) => { {ips.map((ip) => {
const row = byIp.get(ip) const row = byIp.get(ip)
return ( return (
<span key={ip} className="inline-flex items-center gap-1 tabular-nums"> <span key={ip} className="inline-flex items-center gap-1.5 tabular-nums">
{row ? <IpHealthDot health={row} /> : null} {row ? (
{ip} <HealthCheckBadge
status={row.status}
latencyMs={row.latency_ms}
lastCheckedAt={row.last_checked_at}
lastError={row.last_error}
size="xs"
showLatency
/>
) : null}
<span className="font-mono text-sm">{ip}</span>
</span> </span>
) )
})} })}
@@ -124,83 +86,58 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<EmptyState <EmptyState
icon={Link2Icon} icon={Link2Icon}
title="Нет привязок" title="Нет привязок"
description="Создайте привязку на странице сервисов или в таблице поддоменов" description="Привяжите сервис к поддомену"
className="border border-dashed p-4"
/> />
) : ( ) : (
<AppItemGroup className="gap-3"> <AppItemGroup className="gap-0">
{entries.map(([hostname, hostnameBindings], index) => { {entries.map(([hostname, groupBindings], index) => {
const services = uniqueServices(hostnameBindings) const ips = [
const uniqueIps = [
...new Set( ...new Set(
hostnameBindings groupBindings.flatMap((b) =>
.map((b) => b.target_ip) b.target_ips.length > 0
.filter((ip): ip is string => Boolean(ip)), ? b.target_ips
: b.target_ip
? [b.target_ip]
: [],
),
), ),
] ]
return ( return (
<div key={hostname}> <div key={hostname}>
<AppItem variant="outline"> <AppItem size="sm" variant="muted" className="border-0 px-0">
<AppItemContent className="gap-2"> <AppItemContent className="gap-1">
<Tooltip> <AppItemTitle className="font-mono">{hostname}</AppItemTitle>
<TooltipTrigger <div className="text-muted-foreground text-sm">
render={ {uniqueServices(groupBindings).join(', ')}
<AppItemTitle className="cursor-default truncate font-mono">
{hostname}
</AppItemTitle>
}
/>
<TooltipContent>{hostname}</TooltipContent>
</Tooltip>
<div className="flex flex-wrap items-center gap-2">
{services.map((name) => (
<AppBadge key={name}>{name}</AppBadge>
))}
{uniqueIps.length > 0 && (
<span className="text-xs text-muted-foreground">
<HostnameIpsHealth
bindings={hostnameBindings}
ips={uniqueIps}
/>
</span>
)}
{[
...new Set(
hostnameBindings
.map((b) => b.sync_status)
.filter((s): s is string => Boolean(s)),
),
].map((status) => (
<StatusBadge key={status} status={status} />
))}
</div> </div>
<HostnameIpsHealth
bindings={groupBindings}
ips={ips}
/>
</AppItemContent> </AppItemContent>
<AppItemActions> <AppItemActions>
<AppButton <AppBadge variant="outline" className="tabular-nums">
variant="outline" {ips.length} IP
size="sm" </AppBadge>
nativeButton={false}
render={<Link to="/services" search={{ domainId: undefined }} />}
>
Сервисы
</AppButton>
</AppItemActions> </AppItemActions>
</AppItem> </AppItem>
{index < entries.length - 1 && <AppItemSeparator />} {index < entries.length - 1 ? <AppItemSeparator /> : null}
</div> </div>
) )
})} })}
</AppItemGroup> </AppItemGroup>
)} )}
</AppCardContent> </AppCardContent>
{entries.length > 0 && ( <AppCardFooter>
<AppCardFooter> <AppButton
<AppButton variant="link" className="h-auto p-0" nativeButton={false} render={<Link to="/services" search={{ domainId: undefined }} />}> variant="link"
Управление привязками className="h-auto p-0"
</AppButton> nativeButton={false}
</AppCardFooter> render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />}
)} >
К сервисам
</AppButton>
</AppCardFooter>
</AppCard> </AppCard>
) )
} }
@@ -0,0 +1,334 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ActivityIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
import type { DomainMonitorType } from '@/lib/schemas'
import {
createDomainMonitor,
deleteDomainMonitor,
domainMonitorKeys,
domainMonitorResultsQueryOptions,
domainMonitorsQueryOptions,
runDomainMonitors,
} from '@/queries'
import { HealthTimeline } from '@/components/health/health-timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { EmptyState } from '@/components/empty-state'
import { LoadingButton } from '@/components/loading-button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
AppField,
AppFieldGroup,
AppFieldLabel,
} from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
AppItem,
AppItemActions,
AppItemContent,
AppItemGroup,
AppItemSeparator,
AppItemTitle,
} from '@/components/app-item'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { formatDate } from '@/lib/format'
const MONITOR_TYPE_ITEMS: { label: string; value: DomainMonitorType }[] = [
{ label: 'HTTP', value: 'http' },
{ label: 'Ping', value: 'ping' },
{ label: 'DNS', value: 'dns' },
]
function monitorTypeLabel(type: string): string {
const found = MONITOR_TYPE_ITEMS.find((item) => item.value === type)
return found?.label ?? type
}
interface DomainAvailabilityPanelProps {
domainId: number
}
export function DomainAvailabilityPanel({
domainId,
}: DomainAvailabilityPanelProps) {
const queryClient = useQueryClient()
const [hostname, setHostname] = useState('')
const [monitorType, setMonitorType] = useState<DomainMonitorType>('http')
const monitorsQuery = useQuery(domainMonitorsQueryOptions(domainId))
const resultsQuery = useQuery(domainMonitorResultsQueryOptions(domainId, 50))
const invalidateMonitors = async () => {
await queryClient.invalidateQueries({
queryKey: domainMonitorKeys.all,
})
}
const createMutation = useMutation({
mutationFn: () =>
createDomainMonitor(domainId, {
hostname: hostname.trim(),
type: monitorType,
}),
onSuccess: async () => {
setHostname('')
setMonitorType('http')
toast.success('Монитор создан')
await invalidateMonitors()
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось создать монитор',
)
},
})
const deleteMutation = useMutation({
mutationFn: (monitorId: number) =>
deleteDomainMonitor(domainId, monitorId),
onSuccess: async () => {
toast.success('Монитор удалён')
await invalidateMonitors()
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось удалить монитор',
)
},
})
const runMutation = useMutation({
mutationFn: () => runDomainMonitors(domainId),
onSuccess: async (data) => {
toast.success(`Проверено мониторов: ${data.checked}`)
await invalidateMonitors()
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось запустить проверку',
)
},
})
const timelineEvents = useMemo(
() =>
(resultsQuery.data ?? []).map((row) => ({
id: row.id,
hostname: row.hostname,
type: row.type,
status: row.status,
latency_ms: row.latency_ms,
error: row.error,
checked_at: row.checked_at,
})),
[resultsQuery.data],
)
const monitors = monitorsQuery.data ?? []
const canCreate = hostname.trim().length > 0 && !createMutation.isPending
return (
<div className="flex flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-sm">
Мониторы доступности hostname в зоне (HTTP, Ping, DNS)
</p>
<LoadingButton
type="button"
variant="outline"
onClick={() => runMutation.mutate()}
isLoading={runMutation.isPending}
loadingLabel="Проверка…"
disabled={monitors.length === 0}
>
Запустить проверку
</LoadingButton>
</div>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Новый монитор</FrameTitle>
<FrameDescription>
Укажите hostname и тип проверки
</FrameDescription>
</FrameHeader>
<FramePanel>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (!canCreate) return
createMutation.mutate()
}}
>
<AppFieldGroup className="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
<AppField>
<AppFieldLabel htmlFor="monitor-hostname">
Hostname
</AppFieldLabel>
<AppInput
id="monitor-hostname"
value={hostname}
placeholder="example.com"
onChange={(e) => setHostname(e.target.value)}
autoComplete="off"
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="monitor-type">Тип</AppFieldLabel>
<Select
items={MONITOR_TYPE_ITEMS}
value={monitorType}
onValueChange={(value) =>
setMonitorType((value ?? 'http') as DomainMonitorType)
}
>
<SelectTrigger id="monitor-type" className="w-full">
<SelectValue>
{monitorTypeLabel(monitorType)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{MONITOR_TYPE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</AppField>
<LoadingButton
type="submit"
isLoading={createMutation.isPending}
loadingLabel="Создание…"
disabled={!canCreate}
>
<PlusIcon data-icon="inline-start" />
Добавить
</LoadingButton>
</AppFieldGroup>
</form>
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Мониторы</FrameTitle>
<FrameDescription>
Последний статус каждой проверки
</FrameDescription>
</FrameHeader>
<FramePanel>
<QueryState
isLoading={monitorsQuery.isLoading}
isError={monitorsQuery.isError}
error={monitorsQuery.error}
onRetry={() => void monitorsQuery.refetch()}
skeleton={<TableSkeleton rows={3} cols={3} />}
>
{monitors.length === 0 ? (
<EmptyState
icon={ActivityIcon}
title="Мониторы не настроены"
description="Добавьте hostname для проверки доступности"
/>
) : (
<AppItemGroup className="gap-0">
{monitors.map((monitor, index) => (
<div key={monitor.id}>
<AppItem size="sm" variant="muted" className="border-0 px-0">
<AppItemContent className="gap-1">
<AppItemTitle className="font-mono">
{monitor.hostname}
</AppItemTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<span className="uppercase">
{monitorTypeLabel(monitor.type)}
</span>
{monitor.last_checked_at ? (
<span className="tabular-nums">
{formatDate(monitor.last_checked_at)}
</span>
) : (
<span>Ещё не проверялся</span>
)}
</div>
</AppItemContent>
<AppItemActions className="gap-2">
<HealthCheckBadge
status={monitor.last_status}
latencyMs={monitor.last_latency_ms}
lastCheckedAt={monitor.last_checked_at}
lastError={monitor.last_error}
size="xs"
showLatency
/>
<ConfirmDialog
title="Удалить монитор?"
description={`Будет удалён монитор ${monitor.hostname} (${monitorTypeLabel(monitor.type)}).`}
onConfirm={() =>
deleteMutation.mutate(monitor.id)
}
disabled={deleteMutation.isPending}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Удалить монитор ${monitor.hostname}`}
>
<Trash2Icon />
</Button>
}
/>
</AppItemActions>
</AppItem>
{index < monitors.length - 1 ? (
<AppItemSeparator />
) : null}
</div>
))}
</AppItemGroup>
)}
</QueryState>
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>История проверок</FrameTitle>
<FrameDescription>
Последние результаты мониторов зоны
</FrameDescription>
</FrameHeader>
<FramePanel>
<QueryState
isLoading={resultsQuery.isLoading}
isError={resultsQuery.isError}
error={resultsQuery.error}
onRetry={() => void resultsQuery.refetch()}
skeleton={<TableSkeleton rows={4} cols={2} />}
>
<HealthTimeline events={timelineEvents} />
</QueryState>
</FramePanel>
</Frame>
</div>
)
}
@@ -0,0 +1,130 @@
import { useState } from 'react'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Input } from '@cfdm/ui/components/input'
interface DomainsBulkToolbarProps {
count: number
isPending?: boolean
groupItems: { label: string; value: string }[]
onAssignGroup: (groupId: number | null) => void
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
onAddTag: (tag: string) => void
onClear: () => void
}
export function DomainsBulkToolbar({
count,
isPending = false,
groupItems,
onAssignGroup,
onSetEnvironment,
onAddTag,
onClear,
}: DomainsBulkToolbarProps) {
const [groupValue, setGroupValue] = useState('none')
const [envValue, setEnvValue] = useState('none')
const [tag, setTag] = useState('')
if (count === 0) return null
const envItems = [
{ label: 'Без env', value: 'none' },
{ label: 'prod', value: 'prod' },
{ label: 'staging', value: 'staging' },
{ label: 'dev', value: 'dev' },
]
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<span className="text-muted-foreground text-sm tabular-nums">
Выбрано: {count}
</span>
<Select
items={groupItems}
value={groupValue}
onValueChange={(v) => setGroupValue(v ?? 'none')}
>
<SelectTrigger className="w-44" size="sm">
<SelectValue placeholder="Группа" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
}
>
В группу
</Button>
<Select
items={envItems}
value={envValue}
onValueChange={(v) => setEnvValue(v ?? 'none')}
>
<SelectTrigger className="w-32" size="sm">
<SelectValue placeholder="Env" />
</SelectTrigger>
<SelectContent>
{envItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onSetEnvironment(
envValue === 'none'
? null
: (envValue as 'prod' | 'staging' | 'dev'),
)
}
>
Env
</Button>
<Input
className="h-8 w-32"
placeholder="тег"
value={tag}
onChange={(e) => setTag(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || !tag.trim()}
onClick={() => {
onAddTag(tag.trim())
setTag('')
}}
>
+ тег
</Button>
<Button type="button" variant="ghost" size="sm" disabled={isPending} onClick={onClear}>
Снять выделение
</Button>
</div>
)
}
+29 -10
View File
@@ -1,32 +1,41 @@
import { Badge, badgeVariants } from '@cfdm/ui/components/badge' import type { ComponentProps } from 'react'
import { Badge } from '@/components/reui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/ui/components/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/ui/components/tooltip'
import type { VariantProps } from 'class-variance-authority'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import type { IpHealthStatus } from '@/lib/schemas' import type { IpHealthStatus } from '@/lib/schemas'
import { formatDate } from '@/lib/format' import { formatDate } from '@/lib/format'
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']> type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = { const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
up: 'success', up: 'success-light',
degraded: 'secondary', degraded: 'warning-light',
down: 'destructive', down: 'destructive-light',
unknown: 'outline', unknown: 'outline',
} }
const healthLabels: Record<IpHealthStatus['status'], string> = { const healthLabels: Record<IpHealthStatus['status'], string> = {
up: 'OK', up: 'OK',
degraded: 'Деград.', degraded: 'Slow',
down: 'Down', down: 'Down',
unknown: '—', unknown: '—',
} }
const dotColor: Record<IpHealthStatus['status'], string> = {
up: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
unknown: 'bg-muted-foreground',
}
interface HealthCheckBadgeProps { interface HealthCheckBadgeProps {
status: IpHealthStatus['status'] status: IpHealthStatus['status']
latencyMs?: number | null latencyMs?: number | null
lastCheckedAt?: string | null lastCheckedAt?: string | null
lastError?: string | null lastError?: string | null
title?: string title?: string
showLatency?: boolean
size?: 'xs' | 'sm'
className?: string className?: string
} }
@@ -36,6 +45,8 @@ export function HealthCheckBadge({
lastCheckedAt, lastCheckedAt,
lastError, lastError,
title, title,
showLatency = false,
size = 'sm',
className, className,
}: HealthCheckBadgeProps) { }: HealthCheckBadgeProps) {
const variant = healthVariants[status] const variant = healthVariants[status]
@@ -53,16 +64,24 @@ export function HealthCheckBadge({
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<span tabIndex={0} className="inline-flex cursor-default" /> <span tabIndex={0} className="inline-flex cursor-default items-center gap-1.5" />
} }
> >
<Badge variant={variant} className={cn('gap-1.5', className)}> <Badge
variant={variant}
size={size}
radius="full"
className={cn('gap-1.5', className)}
>
<span <span
className="size-1.5 rounded-full bg-current opacity-70" className={cn('size-1.5 shrink-0 rounded-full', dotColor[status])}
aria-hidden aria-hidden
/> />
{label} {label}
</Badge> </Badge>
{showLatency && latencyMs != null ? (
<span className="text-muted-foreground text-xs tabular-nums">{latencyMs} мс</span>
) : null}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent> <TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
</Tooltip> </Tooltip>
@@ -0,0 +1,85 @@
import { Link2Icon } from 'lucide-react'
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import type { IpHealthStatus } from '@/lib/schemas'
import { EmptyState } from '@/components/empty-state'
export interface HealthTimelineEvent {
id: string | number
hostname?: string
type?: string
status: IpHealthStatus['status']
latency_ms?: number | null
error?: string | null
checked_at: string
}
interface HealthTimelineProps {
events: HealthTimelineEvent[]
}
export function HealthTimeline({ events }: HealthTimelineProps) {
if (events.length === 0) {
return (
<EmptyState
icon={Link2Icon}
title="Нет событий"
description="Результаты проверок появятся после первого прогона"
/>
)
}
return (
<Timeline defaultValue={1} className="gap-0">
{events.map((event, index) => {
const checkedIso =
sqliteUtcToIso(event.checked_at) ?? event.checked_at
return (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
{event.hostname ? (
<span className="font-mono text-sm">{event.hostname}</span>
) : null}
{event.type ? (
<span className="text-muted-foreground text-xs uppercase">
{event.type}
</span>
) : null}
<HealthCheckBadge
status={event.status}
latencyMs={event.latency_ms}
size="xs"
showLatency
/>
</TimelineTitle>
<TimelineDate>
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
</TimelineDate>
</TimelineHeader>
{event.error ? (
<TimelineContent>
<code className="bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs">
{event.error}
</code>
</TimelineContent>
) : null}
</TimelineItem>
)
})}
</Timeline>
)
}
@@ -98,7 +98,7 @@ export function OpsDashboard({
<FrameHeader> <FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle> <FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription> <FrameDescription>
Истекающие сертификаты и домены без группы Проблемы health-check, истекающие сертификаты и домены без группы
</FrameDescription> </FrameDescription>
</FrameHeader> </FrameHeader>
<FramePanel>{queue}</FramePanel> <FramePanel>{queue}</FramePanel>
@@ -67,6 +67,11 @@ export interface ResourcePageProps<T extends object> {
emptyState?: { title: string; description?: string; action?: ReactNode } emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number pageSize?: number
enableRowSelection?: boolean enableRowSelection?: boolean
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
toolbarExtra?: ReactNode toolbarExtra?: ReactNode
hideHeader?: boolean hideHeader?: boolean
} }
@@ -113,6 +118,7 @@ export function ResourcePage<T extends object>({
emptyState, emptyState,
pageSize = 10, pageSize = 10,
enableRowSelection = false, enableRowSelection = false,
selectionToolbar,
toolbarExtra, toolbarExtra,
hideHeader = false, hideHeader = false,
}: ResourcePageProps<T>) { }: ResourcePageProps<T>) {
@@ -154,11 +160,17 @@ export function ResourcePage<T extends object>({
return counts return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue]) }, [tabs, tabFilter, data, filters, getFilterFieldValue])
const selectedCount = useMemo( const selectedIds = useMemo(
() => Object.keys(rowSelection).length, () => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection], [rowSelection],
) )
const selectedCount = selectedIds.length
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
const table = useReactTable({ const table = useReactTable({
data: filteredData, data: filteredData,
columns, columns,
@@ -245,6 +257,13 @@ export function ResourcePage<T extends object>({
return ( return (
<div ref={frameRef} className="w-full"> <div ref={frameRef} className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid <DataGrid
table={table} table={table}
recordCount={filteredData.length} recordCount={filteredData.length}
+43 -9
View File
@@ -1,21 +1,38 @@
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']> type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = { const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success', active: 'success-light',
synced: 'success', synced: 'success-light',
ok: 'success', ok: 'success-light',
up: 'success-light',
pending_push: 'secondary', pending_push: 'secondary',
warning: 'warning', warning: 'warning-light',
conflict: 'destructive', degraded: 'warning-light',
error: 'destructive', conflict: 'destructive-light',
expired: 'destructive', error: 'destructive-light',
expired: 'destructive-light',
down: 'destructive-light',
unknown: 'outline', unknown: 'outline',
} }
const DOT_COLOR: Record<string, string> = {
'success-light': 'bg-success',
success: 'bg-success',
'warning-light': 'bg-warning',
warning: 'bg-warning',
'destructive-light': 'bg-destructive',
destructive: 'bg-destructive',
'info-light': 'bg-info',
secondary: 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
active: 'Активен', active: 'Активен',
synced: 'Синхронизировано', synced: 'Синхронизировано',
@@ -23,12 +40,29 @@ const STATUS_LABELS: Record<string, string> = {
conflict: 'Конфликт', conflict: 'Конфликт',
error: 'Ошибка', error: 'Ошибка',
ok: 'OK', ok: 'OK',
up: 'OK',
warning: 'Предупреждение', warning: 'Предупреждение',
degraded: 'Slow',
down: 'Down',
expired: 'Истёк', expired: 'Истёк',
unknown: 'Неизвестно', unknown: 'Неизвестно',
} }
export function StatusBadge({ status, label }: { status: string; label?: string }) { export function StatusBadge({
status,
label,
className,
}: {
status: string
label?: string
className?: string
}) {
const variant = STATUS_VARIANT[status] ?? 'outline' const variant = STATUS_VARIANT[status] ?? 'outline'
return <Badge variant={variant}>{label ?? STATUS_LABELS[status] ?? status}</Badge> const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<Badge variant={variant} size="sm" radius="full" className={cn('gap-1.5', className)}>
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? STATUS_LABELS[status] ?? status}
</Badge>
)
} }
+32
View File
@@ -0,0 +1,32 @@
import { useQueries } from '@tanstack/react-query'
import { useMemo } from 'react'
import { healthStatusQueryOptions } from '@/queries'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
/** Map IP → worst health status across enabled bindings. */
export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
const enabledBindings = useMemo(
() => (bindings ?? []).filter((b) => b.health_check_enabled),
[bindings],
)
const queries = useQueries({
queries: enabledBindings.map((b) => ({
...healthStatusQueryOptions('binding', b.id),
})),
})
return useMemo(() => {
const map: Record<string, IpHealthStatus> = {}
const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const
for (const q of queries) {
for (const row of q.data ?? []) {
const prev = map[row.ip]
if (!prev || rank[row.status] > rank[prev.status]) {
map[row.ip] = row
}
}
}
return map
}, [queries])
}
+15
View File
@@ -115,6 +115,7 @@ export const domainSchema = z.object({
cf_zone_id: z.string(), cf_zone_id: z.string(),
status: z.string(), status: z.string(),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'), cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
environment: z.enum(['prod', 'staging', 'dev']).nullable().optional().default(null),
last_synced_at: z.string().nullable(), last_synced_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
@@ -123,6 +124,9 @@ export const domainSchema = z.object({
export const domainListItemSchema = domainSchema.extend({ export const domainListItemSchema = domainSchema.extend({
group_name: z.string().nullable(), group_name: z.string().nullable(),
service_count: z.number(), service_count: z.number(),
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
health_latency_ms: z.number().nullable().default(null),
tags: z.array(z.string()).default([]),
}) })
export const serviceBindingSchema = z export const serviceBindingSchema = z
@@ -369,11 +373,22 @@ export type LoginInput = z.infer<typeof loginSchema>
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema> export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
export { export {
bulkUpdateDomainsSchema,
createDomainMonitorSchema,
createSubdomainSchema, createSubdomainSchema,
domainMonitorResultSchema,
domainMonitorSchema,
notificationLogSchema,
subdomainSchema, subdomainSchema,
updateDomainSchema, updateDomainSchema,
updateSubdomainSchema, updateSubdomainSchema,
type BulkUpdateDomainsInput,
type CreateDomainMonitorInput,
type CreateSubdomainInput, type CreateSubdomainInput,
type DomainMonitor,
type DomainMonitorResult,
type DomainMonitorType,
type NotificationLog,
type SubdomainRecord, type SubdomainRecord,
type UpdateDomainInput, type UpdateDomainInput,
type UpdateSubdomainInput, type UpdateSubdomainInput,
+25
View File
@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { certificateSchema } from '@/lib/schemas'
import { z } from 'zod'
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
}
export const certificatesQueryOptions = () =>
queryOptions({
queryKey: certKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/certificates')
return z.array(certificateSchema).parse(data)
},
staleTime: 1000 * 60 * 5,
})
export const certSummaryQueryOptions = () =>
queryOptions({
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
+19
View File
@@ -0,0 +1,19 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { dnsRecordSchema } from '@/lib/schemas'
import { z } from 'zod'
export const dnsKeys = {
all: ['dns'] as const,
list: (domainId: number) => [...dnsKeys.all, domainId] as const,
}
export const dnsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: dnsKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/dns`)
return z.array(dnsRecordSchema).parse(data)
},
staleTime: 1000 * 30,
})
+111
View File
@@ -0,0 +1,111 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
domainMonitorResultSchema,
domainMonitorSchema,
ipHealthStatusSchema,
notificationLogSchema,
createDomainMonitorSchema,
} from '@/lib/schemas'
import { z } from 'zod'
type CreateDomainMonitorBody = z.input<typeof createDomainMonitorSchema>
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
export const domainMonitorKeys = {
all: ['domain-monitors'] as const,
list: (domainId: number) => [...domainMonitorKeys.all, 'list', domainId] as const,
results: (domainId: number, limit?: number) =>
[...domainMonitorKeys.all, 'results', domainId, limit] as const,
}
export const domainMonitorsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: domainMonitorKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/monitors`)
return z.array(domainMonitorSchema).parse(data)
},
})
/** Domain-scoped results include joined hostname/type from monitors. */
export const domainMonitorResultEventSchema = domainMonitorResultSchema.extend({
hostname: z.string(),
type: z.string(),
})
export type DomainMonitorResultEvent = z.infer<
typeof domainMonitorResultEventSchema
>
export const domainMonitorResultsQueryOptions = (domainId: number, limit = 50) =>
queryOptions({
queryKey: domainMonitorKeys.results(domainId, limit),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/domains/${domainId}/monitor-results?limit=${limit}`,
)
return z.array(domainMonitorResultEventSchema).parse(data)
},
})
export async function createDomainMonitor(
domainId: number,
body: CreateDomainMonitorBody,
) {
const data = await api.post<unknown>(
`/api/v1/domains/${domainId}/monitors`,
createDomainMonitorSchema.parse(body),
)
return domainMonitorSchema.parse(data)
}
export async function deleteDomainMonitor(domainId: number, monitorId: number) {
return api.delete<{ deleted: boolean }>(
`/api/v1/domains/${domainId}/monitors/${monitorId}`,
)
}
export async function runDomainMonitors(domainId: number) {
return api.post<{ checked: number }>(`/api/v1/domains/${domainId}/monitors/run`, {})
}
export const notificationLogKeys = {
all: ['notification-log'] as const,
list: (limit?: number) => [...notificationLogKeys.all, 'list', limit] as const,
}
export const notificationLogQueryOptions = (limit = 50) =>
queryOptions({
queryKey: notificationLogKeys.list(limit),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/notifications/log?limit=${limit}`)
return z.array(notificationLogSchema).parse(data)
},
})
+86
View File
@@ -0,0 +1,86 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
domainListItemSchema,
domainSchema,
subdomainSchema,
type BulkUpdateDomainsInput,
type CreateSubdomainInput,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@/lib/schemas'
import { dnsKeys } from '@/queries/dns'
import { serviceBindingKeys } from '@/queries/services'
import { z } from 'zod'
export const domainKeys = {
all: ['domains'] as const,
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
detail: (id: number) => [...domainKeys.all, 'detail', id] as const,
}
export const domainsListQueryOptions = (groupId?: number) =>
queryOptions({
queryKey: domainKeys.list(groupId),
queryFn: async () => {
const qs = groupId ? `?group_id=${groupId}` : ''
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
return z.array(domainListItemSchema).parse(data)
},
})
export const domainDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: domainKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/domains/${id}`)
return domainSchema.parse(data)
},
})
export const subdomainKeys = {
all: ['subdomains'] as const,
list: (domainId: number) => [...subdomainKeys.all, domainId] as const,
}
export const subdomainsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: subdomainKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/subdomains`)
return z.array(subdomainSchema).parse(data)
},
})
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function bulkUpdateDomains(body: BulkUpdateDomainsInput) {
return api.post<{ updated: number }>('/api/v1/domains/bulk', body)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
+27
View File
@@ -0,0 +1,27 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { groupSchema, groupWithStatsSchema } from '@/lib/schemas'
import { z } from 'zod'
export const groupKeys = {
all: ['groups'] as const,
detail: (id: number) => [...groupKeys.all, 'detail', id] as const,
}
export const groupsQueryOptions = () =>
queryOptions({
queryKey: groupKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/groups')
return z.array(groupSchema).parse(data)
},
})
export const groupDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: groupKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/groups/${id}`)
return groupWithStatsSchema.parse(data)
},
})
+6 -238
View File
@@ -1,238 +1,6 @@
import { queryOptions } from '@tanstack/react-query' export * from '@/queries/certificates'
import { api } from '@/lib/api-client' export * from '@/queries/dns'
import { export * from '@/queries/domain-health'
certificateSchema, export * from '@/queries/domains'
dnsRecordSchema, export * from '@/queries/groups'
domainListItemSchema, export * from '@/queries/services'
domainSchema,
groupSchema,
groupWithStatsSchema,
ipHealthStatusSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
subdomainSchema,
type CreateSubdomainInput,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@/lib/schemas'
import { z } from 'zod'
export const groupKeys = {
all: ['groups'] as const,
detail: (id: number) => [...groupKeys.all, 'detail', id] as const,
}
export const groupsQueryOptions = () =>
queryOptions({
queryKey: groupKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/groups')
return z.array(groupSchema).parse(data)
},
})
export const groupDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: groupKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/groups/${id}`)
return groupWithStatsSchema.parse(data)
},
})
export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceViewSchema).parse(data)
},
})
export const serviceBindingKeys = {
all: ['service-bindings'] as const,
byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const,
}
export const serviceBindingsQueryOptions = () =>
queryOptions({
queryKey: serviceBindingKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/service-bindings')
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainServiceBindingsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: serviceBindingKeys.byDomain(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/service-bindings`)
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainKeys = {
all: ['domains'] as const,
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
detail: (id: number) => [...domainKeys.all, 'detail', id] as const,
}
export const domainsListQueryOptions = (groupId?: number) =>
queryOptions({
queryKey: domainKeys.list(groupId),
queryFn: async () => {
const qs = groupId ? `?group_id=${groupId}` : ''
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
return z.array(domainListItemSchema).parse(data)
},
})
export const domainDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: domainKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/domains/${id}`)
return domainSchema.parse(data)
},
})
export const dnsKeys = {
all: ['dns'] as const,
list: (domainId: number) => [...dnsKeys.all, domainId] as const,
}
export const dnsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: dnsKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/dns`)
return z.array(dnsRecordSchema).parse(data)
},
staleTime: 1000 * 30,
})
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
}
export const certificatesQueryOptions = () =>
queryOptions({
queryKey: certKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/certificates')
return z.array(certificateSchema).parse(data)
},
staleTime: 1000 * 60 * 5,
})
export const certSummaryQueryOptions = () =>
queryOptions({
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
export const subdomainKeys = {
all: ['subdomains'] as const,
list: (domainId: number) => [...subdomainKeys.all, domainId] as const,
}
export const subdomainsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: subdomainKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/subdomains`)
return z.array(subdomainSchema).parse(data)
},
})
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
+73
View File
@@ -0,0 +1,73 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
} from '@/lib/schemas'
import { z } from 'zod'
export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceViewSchema).parse(data)
},
})
export const serviceBindingKeys = {
all: ['service-bindings'] as const,
byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const,
}
export const serviceBindingsQueryOptions = () =>
queryOptions({
queryKey: serviceBindingKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/service-bindings')
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainServiceBindingsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: serviceBindingKeys.byDomain(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/service-bindings`)
return z.array(serviceBindingSchema).parse(data)
},
})
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
@@ -7,7 +7,8 @@ import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas' import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries' import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, domainServiceBindingsQueryOptions, subdomainKeys } from '@/queries'
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { DetailPanel, ResourcePage } from '@/components/reui-kit' import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import { import {
@@ -44,7 +45,10 @@ export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
loader: async ({ context: { queryClient }, params }) => { loader: async ({ context: { queryClient }, params }) => {
const id = Number(params.domainId) const id = Number(params.domainId)
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id)) const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
await queryClient.ensureQueryData(dnsListQueryOptions(id)) await Promise.all([
queryClient.ensureQueryData(dnsListQueryOptions(id)),
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
])
return { breadcrumb: domain.zone_name } return { breadcrumb: domain.zone_name }
}, },
component: DnsPage, component: DnsPage,
@@ -74,6 +78,8 @@ function DnsPage() {
error: recordsErr, error: recordsErr,
refetch: refetchRecords, refetch: refetchRecords,
} = useQuery(dnsListQueryOptions(id)) } = useQuery(dnsListQueryOptions(id))
const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id))
const healthByIp = useDomainHealthByIp(bindings)
const isLoading = domainLoading || recordsLoading const isLoading = domainLoading || recordsLoading
const isError = domainError || recordsError const isError = domainError || recordsError
@@ -138,6 +144,7 @@ function DnsPage() {
const columns = useDnsColumns({ const columns = useDnsColumns({
onDelete: (recordId) => deleteMutation.mutate(recordId), onDelete: (recordId) => deleteMutation.mutate(recordId),
isDeleting: deleteMutation.isPending, isDeleting: deleteMutation.isPending,
healthByIp,
}) })
const displayRecords = useMemo(() => { const displayRecords = useMemo(() => {
@@ -1,5 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
FolderTreeIcon, FolderTreeIcon,
GlobeIcon, GlobeIcon,
@@ -7,17 +8,22 @@ import {
ServerIcon, ServerIcon,
ShieldCheckIcon, ShieldCheckIcon,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import type { CertMonitoring } from '@cfdm/shared' import type { CertMonitoring } from '@cfdm/shared'
import { import {
domainDetailQueryOptions, domainDetailQueryOptions,
domainServiceBindingsQueryOptions, domainServiceBindingsQueryOptions,
healthStatusKeys,
runHealthCheck,
serviceGroupsQueryOptions, serviceGroupsQueryOptions,
servicesQueryOptions, servicesQueryOptions,
subdomainsListQueryOptions, subdomainsListQueryOptions,
} from '@/queries' } from '@/queries'
import { useDomainPage } from '@/hooks/use-domain-page' import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page' import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
import { aggregateHealth } from '@/lib/use-aggregated-health'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { DetailPanel, ResourcePage } from '@/components/reui-kit' import { DetailPanel, ResourcePage } from '@/components/reui-kit'
@@ -34,6 +40,8 @@ import {
type SubdomainEditValues, type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet' } from '@/components/subdomain-edit-sheet'
import { DomainBindingsCard } from '@/components/domain-bindings-card' import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring' import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format' import { formatDate } from '@/lib/format'
@@ -48,6 +56,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@cfdm/ui/components/select' } from '@cfdm/ui/components/select'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({ export const Route = createFileRoute('/_auth/domains/$domainId/')({
loader: async ({ context: { queryClient }, params }) => { loader: async ({ context: { queryClient }, params }) => {
@@ -81,10 +95,12 @@ function DomainPageSkeleton() {
function DomainOverviewPage() { function DomainOverviewPage() {
const { domainId } = Route.useParams() const { domainId } = Route.useParams()
const id = Number(domainId) const id = Number(domainId)
const queryClient = useQueryClient()
const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters) const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create') const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null) const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
const [activeTab, setActiveTab] = useState('overview')
const { const {
domain, domain,
@@ -104,6 +120,25 @@ function DomainOverviewPage() {
linkServiceMutation, linkServiceMutation,
} = useDomainPage(id) } = useDomainPage(id)
const healthByIp = useDomainHealthByIp(bindings)
const aggregatedHealth = useMemo(
() => aggregateHealth(Object.values(healthByIp)),
[healthByIp],
)
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() { function openCreateSheet() {
setSheetMode('create') setSheetMode('create')
setEditTarget(null) setEditTarget(null)
@@ -271,8 +306,16 @@ function DomainOverviewPage() {
/> />
} }
> >
DNS-записи DNS
</Button> </Button>
<LoadingButton
variant="outline"
onClick={() => runCheckMutation.mutate()}
isLoading={runCheckMutation.isPending}
loadingLabel="Проверка…"
>
Проверить сейчас
</LoadingButton>
</> </>
) )
@@ -289,80 +332,181 @@ function DomainOverviewPage() {
<DetailPanel> <DetailPanel>
<DetailPanel.Header <DetailPanel.Header
title={domain.zone_name} title={domain.zone_name}
description="Обзор домена и поддоменов" description="Карточка домена"
actions={headerActions} actions={headerActions}
> >
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3"> <div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground flex items-center gap-2 text-sm"> <HealthCheckBadge
<ShieldCheckIcon className="size-4" aria-hidden="true" /> status={aggregatedHealth.status}
Мониторинг SSL (apex): latencyMs={aggregatedHealth.worstLatencyMs}
</span> lastCheckedAt={aggregatedHealth.lastCheckedAt}
<Select lastError={aggregatedHealth.lastError}
items={certMonitoringItems} title="Агрегат по привязкам"
value={domain.cert_monitoring} showLatency
onValueChange={(value) => />
updateDomainCertMonitoringMutation.mutate( {aggregatedHealth.total > 0 ? (
(value ?? 'auto') as CertMonitoring, <span className="text-muted-foreground text-xs tabular-nums">
) {aggregatedHealth.upCount}/{aggregatedHealth.total} OK
} </span>
disabled={updateDomainCertMonitoringMutation.isPending} ) : null}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</DetailPanel.Header> </DetailPanel.Header>
<DetailPanel.Metrics cards={metricCards} /> <Tabs
value={activeTab}
<DetailPanel.Section onValueChange={setActiveTab}
title="Привязки сервисов" className="flex w-full flex-col gap-4"
description="Сервисы, назначенные hostname в этой зоне"
> >
<DomainBindingsCard bindings={bindings} /> <TabsList variant="line" className="gap-5">
</DetailPanel.Section> <TabsTrigger
value="overview"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Обзор</span>
</TabsTrigger>
<TabsTrigger
value="dns"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>DNS</span>
</TabsTrigger>
<TabsTrigger
value="availability"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Доступность</span>
</TabsTrigger>
<TabsTrigger
value="subdomains"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Поддомены</span>
<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">
{subdomainRows.length}
</span>
</TabsTrigger>
<TabsTrigger
value="bindings"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Привязки</span>
<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">
{bindings.length}
</span>
</TabsTrigger>
</TabsList>
<DetailPanel.Section title="Поддомены"> <TabsContent value="overview" className="flex flex-col gap-4">
<ResourcePage <DetailPanel.Metrics cards={metricCards} />
title="Поддомены" <DetailPanel.Section
description={`Записи в зоне ${domain.zone_name}`} title="Мониторинг SSL"
hideHeader description="Настройка проверки сертификата для apex-зоны"
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))} >
tabFilter={subdomainTabFilter} <div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
filterFields={filterFields} <span className="text-muted-foreground flex items-center gap-2 text-sm">
filters={filters} <ShieldCheckIcon className="size-4" aria-hidden="true" />
onFiltersChange={setFilters} Мониторинг SSL (apex):
onClearFilters={() => setFilters(createDefaultSubdomainFilters())} </span>
getFilterFieldValue={subdomainFilterFieldValue} <Select
columns={columns} items={certMonitoringItems}
data={subdomainRows} value={domain.cert_monitoring}
getRowId={(row) => String(row.subdomain.id)} onValueChange={(value) =>
toolbarExtra={ updateDomainCertMonitoringMutation.mutate(
<Button type="button" onClick={openCreateSheet}> (value ?? 'auto') as CertMonitoring,
Добавить поддомен )
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="dns" className="flex flex-col gap-4">
<DetailPanel.Section
title="DNS-записи"
description="Управление записями зоны в Cloudflare"
>
<p className="text-muted-foreground text-sm">
Полный редактор DNS вынесен на отдельную страницу.
</p>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
Открыть DNS
</Button> </Button>
} </DetailPanel.Section>
emptyState={{ </TabsContent>
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов', <TabsContent value="availability" className="flex flex-col gap-4">
action: ( <DomainAvailabilityPanel domainId={id} />
<Button type="button" onClick={openCreateSheet}> </TabsContent>
Добавить поддомен
</Button> <TabsContent value="subdomains" className="flex flex-col gap-4">
), <DetailPanel.Section title="Поддомены">
}} <ResourcePage
/> title="Поддомены"
</DetailPanel.Section> 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}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<DetailPanel.Section
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
>
<DomainBindingsCard bindings={bindings} />
</DetailPanel.Section>
</TabsContent>
</Tabs>
<SubdomainEditSheet <SubdomainEditSheet
mode={sheetMode} mode={sheetMode}
+46 -10
View File
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
@@ -6,8 +6,9 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas' import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput } from '@/lib/schemas'
import { import {
bulkUpdateDomains,
domainKeys, domainKeys,
domainsListQueryOptions, domainsListQueryOptions,
groupsQueryOptions, groupsQueryOptions,
@@ -23,6 +24,7 @@ import {
useDomainColumns, useDomainColumns,
useDomainFilterFields, useDomainFilterFields,
} from '@/components/columns/domains-columns' } from '@/components/columns/domains-columns'
import { DomainsBulkToolbar } from '@/components/domains/domains-bulk-toolbar'
import { FormSheet } from '@/components/form-sheet' import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field' import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
@@ -106,9 +108,21 @@ function DomainsPage() {
}, },
}) })
const bulkMutation = useMutation({
mutationFn: (body: BulkUpdateDomainsInput) => bulkUpdateDomains(body),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
toast.success(`Обновлено: ${data.updated}`)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить')
},
})
const { columns } = useDomainColumns({ const { columns } = useDomainColumns({
onRequestDelete: setDeleteTarget, onRequestDelete: setDeleteTarget,
isDeleting: deleteMutation.isPending, isDeleting: deleteMutation.isPending,
enableSelection: true,
}) })
const handleCreate = (values: CreateDomainInput) => { const handleCreate = (values: CreateDomainInput) => {
@@ -125,14 +139,9 @@ function DomainsPage() {
} }
const primaryAction = ( const primaryAction = (
<> <Button type="button" onClick={() => setSheetOpen(true)}>
<Button type="button" variant="outline" nativeButton={false} render={<Link to="/groups" />}> Импортировать домен
Группы </Button>
</Button>
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
</>
) )
return ( return (
@@ -155,6 +164,33 @@ function DomainsPage() {
error={error} error={error}
onRetry={refetch} onRetry={refetch}
primaryAction={primaryAction} primaryAction={primaryAction}
enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => (
<DomainsBulkToolbar
count={selectedIds.length}
isPending={bulkMutation.isPending}
groupItems={groupItems}
onAssignGroup={(groupId) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), group_id: groupId },
{ onSuccess: () => clearSelection() },
)
}}
onSetEnvironment={(environment) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), environment },
{ onSuccess: () => clearSelection() },
)
}}
onAddTag={(tag) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), tags_add: [tag] },
{ onSuccess: () => clearSelection() },
)
}}
onClear={clearSelection}
/>
)}
emptyState={{ emptyState={{
title: 'Домены не импортированы', title: 'Домены не импортированы',
description: 'Импортируйте зону из аккаунта Cloudflare', description: 'Импортируйте зону из аккаунта Cloudflare',
+66 -4
View File
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect } from 'react'
import { import {
ActivityIcon,
AlertTriangleIcon, AlertTriangleIcon,
FolderTreeIcon, FolderTreeIcon,
} from 'lucide-react' } from 'lucide-react'
@@ -19,6 +20,7 @@ import {
OpsDashboard, OpsDashboard,
type KpiStatCard, type KpiStatCard,
} from '@/components/reui-kit' } from '@/components/reui-kit'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { import {
Item, Item,
@@ -99,6 +101,22 @@ function DashboardPage() {
[domains], [domains],
) )
const attentionDomains = useMemo(
() =>
(domains ?? [])
.filter((d) => d.health_status === 'down' || d.health_status === 'degraded')
.slice(0, 8),
[domains],
)
const attentionCount = useMemo(
() =>
(domains ?? []).filter(
(d) => d.health_status === 'down' || d.health_status === 'degraded',
).length,
[domains],
)
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error']) const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
const certOk = countByStatus(summary, ['active', 'ok']) const certOk = countByStatus(summary, ['active', 'ok'])
@@ -128,9 +146,11 @@ function DashboardPage() {
label: 'Домены', label: 'Домены',
value: domains?.length ?? 0, value: domains?.length ?? 0,
hint: hint:
ungroupedCount > 0 attentionCount > 0
? `${ungroupedCount} без группы` ? `${attentionCount} требуют внимания`
: 'Все в группах', : ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
to: '/domains', to: '/domains',
}, },
{ {
@@ -174,7 +194,49 @@ function DashboardPage() {
</> </>
} }
queue={ queue={
<div className="grid gap-3 lg:grid-cols-2"> <div className="grid gap-3 lg:grid-cols-3">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<ActivityIcon
className="text-destructive size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Проблемы health-check</h3>
{attentionCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({attentionCount})
</span>
) : null}
</div>
{attentionDomains.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет доменов со статусом Down или Slow
</p>
) : (
<ItemGroup className="gap-2">
{attentionDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
className="hover:underline"
>
{domain.zone_name}
</Link>
</ItemTitle>
<HealthCheckBadge
status={domain.health_status ?? 'unknown'}
latencyMs={domain.health_latency_ms}
size="xs"
/>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangleIcon <AlertTriangleIcon
+1266 -3
View File
File diff suppressed because it is too large Load Diff
+232 -8
View File
@@ -62,6 +62,7 @@ var domains = sqliteTable("domains", {
cf_zone_id: text("cf_zone_id").notNull(), cf_zone_id: text("cf_zone_id").notNull(),
status: text("status").notNull().default("active"), status: text("status").notNull().default("active"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"), cert_monitoring: text("cert_monitoring").notNull().default("auto"),
environment: text("environment"),
last_synced_at: text("last_synced_at"), last_synced_at: text("last_synced_at"),
created_at: text("created_at").notNull().default(sql`datetime('now')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
@@ -196,6 +197,48 @@ var appSettings = sqliteTable("app_settings", {
created_at: text("created_at").notNull().default(sql`datetime('now')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
}); });
var domainTags = sqliteTable(
"domain_tags",
{
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
tag: text("tag").notNull()
},
(t) => [primaryKey({ columns: [t.domain_id, t.tag] })]
);
var domainMonitors = sqliteTable("domain_monitors", {
id: integer("id").primaryKey({ autoIncrement: true }),
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
type: text("type").notNull().default("http"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
interval_sec: integer("interval_sec").notNull().default(60),
timeout_ms: integer("timeout_ms").notNull().default(5e3),
path: text("path"),
expected_status: integer("expected_status"),
last_status: text("last_status").notNull().default("unknown"),
last_latency_ms: integer("last_latency_ms"),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
var domainMonitorResults = sqliteTable("domain_monitor_results", {
id: integer("id").primaryKey({ autoIncrement: true }),
monitor_id: integer("monitor_id").notNull().references(() => domainMonitors.id, { onDelete: "cascade" }),
status: text("status").notNull(),
latency_ms: integer("latency_ms"),
error: text("error"),
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
});
var notificationLog = sqliteTable("notification_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
kind: text("kind").notNull(),
ref_type: text("ref_type").notNull(),
ref_id: integer("ref_id"),
title: text("title").notNull(),
message: text("message").notNull(),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
});
var schema = { var schema = {
groups, groups,
services, services,
@@ -211,7 +254,11 @@ var schema = {
certificates, certificates,
syncJobs, syncJobs,
ipHealthStatus, ipHealthStatus,
appSettings appSettings,
domainTags,
domainMonitors,
domainMonitorResults,
notificationLog
}; };
// src/client.ts // src/client.ts
@@ -367,9 +414,11 @@ function getAppSwitcher(db) {
// src/repos.ts // src/repos.ts
var repos_exports = {}; var repos_exports = {};
__export(repos_exports, { __export(repos_exports, {
addDomainTags: () => addDomainTags,
bindingsToRemove: () => bindingsToRemove, bindingsToRemove: () => bindingsToRemove,
countCertificatesByStatus: () => countCertificatesByStatus, countCertificatesByStatus: () => countCertificatesByStatus,
createDomain: () => createDomain, createDomain: () => createDomain,
createDomainMonitor: () => createDomainMonitor,
createGroup: () => createGroup, createGroup: () => createGroup,
createService: () => createService, createService: () => createService,
createServiceGroup: () => createServiceGroup, createServiceGroup: () => createServiceGroup,
@@ -380,6 +429,7 @@ __export(repos_exports, {
deleteCertificatesNotIn: () => deleteCertificatesNotIn, deleteCertificatesNotIn: () => deleteCertificatesNotIn,
deleteDnsRecord: () => deleteDnsRecord, deleteDnsRecord: () => deleteDnsRecord,
deleteDomain: () => deleteDomain, deleteDomain: () => deleteDomain,
deleteDomainMonitor: () => deleteDomainMonitor,
deleteGroup: () => deleteGroup, deleteGroup: () => deleteGroup,
deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp, deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp,
deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef, deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef,
@@ -396,6 +446,7 @@ __export(repos_exports, {
getCertificate: () => getCertificate, getCertificate: () => getCertificate,
getDnsRecord: () => getDnsRecord, getDnsRecord: () => getDnsRecord,
getDomain: () => getDomain, getDomain: () => getDomain,
getDomainMonitor: () => getDomainMonitor,
getGroup: () => getGroup, getGroup: () => getGroup,
getGroupWithStats: () => getGroupWithStats, getGroupWithStats: () => getGroupWithStats,
getIpHealthStatusRow: () => getIpHealthStatusRow, getIpHealthStatusRow: () => getIpHealthStatusRow,
@@ -405,6 +456,7 @@ __export(repos_exports, {
getSyncJob: () => getSyncJob, getSyncJob: () => getSyncJob,
insertBinding: () => insertBinding, insertBinding: () => insertBinding,
insertDnsRecord: () => insertDnsRecord, insertDnsRecord: () => insertDnsRecord,
insertNotificationLog: () => insertNotificationLog,
linkBindingRecord: () => linkBindingRecord, linkBindingRecord: () => linkBindingRecord,
linkGroupDnsRecord: () => linkGroupDnsRecord, linkGroupDnsRecord: () => linkGroupDnsRecord,
listAllBindings: () => listAllBindings, listAllBindings: () => listAllBindings,
@@ -417,12 +469,18 @@ __export(repos_exports, {
listCertificates: () => listCertificates, listCertificates: () => listCertificates,
listDnsByDomain: () => listDnsByDomain, listDnsByDomain: () => listDnsByDomain,
listDnsRecords: () => listDnsRecords, listDnsRecords: () => listDnsRecords,
listDomainMonitorResults: () => listDomainMonitorResults,
listDomainMonitorResultsForDomain: () => listDomainMonitorResultsForDomain,
listDomainMonitors: () => listDomainMonitors,
listDomainTags: () => listDomainTags,
listDomains: () => listDomains, listDomains: () => listDomains,
listDomainsEnriched: () => listDomainsEnriched, listDomainsEnriched: () => listDomainsEnriched,
listEnabledDomainMonitors: () => listEnabledDomainMonitors,
listGroupDnsRecords: () => listGroupDnsRecords, listGroupDnsRecords: () => listGroupDnsRecords,
listGroups: () => listGroups, listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets, listHealthCheckTargets: () => listHealthCheckTargets,
listIpHealthStatus: () => listIpHealthStatus, listIpHealthStatus: () => listIpHealthStatus,
listNotificationLog: () => listNotificationLog,
listRecordsForBinding: () => listRecordsForBinding, listRecordsForBinding: () => listRecordsForBinding,
listServiceGroups: () => listServiceGroups, listServiceGroups: () => listServiceGroups,
listServiceIps: () => listServiceIps, listServiceIps: () => listServiceIps,
@@ -439,6 +497,7 @@ __export(repos_exports, {
setBindingDnsRecordId: () => setBindingDnsRecordId, setBindingDnsRecordId: () => setBindingDnsRecordId,
setDnsSyncStatus: () => setDnsSyncStatus, setDnsSyncStatus: () => setDnsSyncStatus,
setDomainLastSynced: () => setDomainLastSynced, setDomainLastSynced: () => setDomainLastSynced,
setDomainTags: () => setDomainTags,
setServiceEnabled: () => setServiceEnabled, setServiceEnabled: () => setServiceEnabled,
setServiceGroup: () => setServiceGroup, setServiceGroup: () => setServiceGroup,
setServiceGroupEnabled: () => setServiceGroupEnabled, setServiceGroupEnabled: () => setServiceGroupEnabled,
@@ -449,6 +508,7 @@ __export(repos_exports, {
updateBindingLbConfig: () => updateBindingLbConfig, updateBindingLbConfig: () => updateBindingLbConfig,
updateDnsFields: () => updateDnsFields, updateDnsFields: () => updateDnsFields,
updateDomain: () => updateDomain, updateDomain: () => updateDomain,
updateDomainMonitorResult: () => updateDomainMonitorResult,
updateGroup: () => updateGroup, updateGroup: () => updateGroup,
updateService: () => updateService, updateService: () => updateService,
updateServiceGroup: () => updateServiceGroup, updateServiceGroup: () => updateServiceGroup,
@@ -497,14 +557,61 @@ function listDomains(db, groupId) {
} }
function listDomainsEnriched(db, groupId) { function listDomainsEnriched(db, groupId) {
const base = groupId != null ? sql2`WHERE d.group_id = ${groupId}` : sql2``; const base = groupId != null ? sql2`WHERE d.group_id = ${groupId}` : sql2``;
return db.all(sql2` const rows = db.all(sql2`
SELECT d.*, g.name AS group_name, SELECT d.*, g.name AS group_name,
(SELECT COUNT(*) FROM service_bindings sb WHERE sb.domain_id = d.id) AS service_count (SELECT COUNT(*) FROM service_bindings sb WHERE sb.domain_id = d.id) AS service_count,
COALESCE((
SELECT CASE
WHEN MAX(CASE
WHEN ihs.status = 'down' THEN 3
WHEN ihs.status = 'degraded' THEN 2
WHEN ihs.status = 'up' THEN 1
ELSE 0
END) = 3 THEN 'down'
WHEN MAX(CASE
WHEN ihs.status = 'down' THEN 3
WHEN ihs.status = 'degraded' THEN 2
WHEN ihs.status = 'up' THEN 1
ELSE 0
END) = 2 THEN 'degraded'
WHEN MAX(CASE
WHEN ihs.status = 'down' THEN 3
WHEN ihs.status = 'degraded' THEN 2
WHEN ihs.status = 'up' THEN 1
ELSE 0
END) = 1 THEN 'up'
ELSE 'unknown'
END
FROM ip_health_status ihs
INNER JOIN service_bindings sb ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.domain_id = d.id
), 'unknown') AS health_status,
(
SELECT MAX(ihs.latency_ms)
FROM ip_health_status ihs
INNER JOIN service_bindings sb ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.domain_id = d.id
) AS health_latency_ms,
(
SELECT GROUP_CONCAT(dt.tag, ',')
FROM domain_tags dt
WHERE dt.domain_id = d.id
) AS tags_json
FROM domains d FROM domains d
LEFT JOIN groups g ON g.id = d.group_id LEFT JOIN groups g ON g.id = d.group_id
${base} ${base}
ORDER BY d.zone_name ASC ORDER BY d.zone_name ASC
`); `);
return rows.map((row) => {
const { tags_json, ...rest } = row;
return {
...rest,
environment: rest.environment ?? null,
health_status: rest.health_status ?? "unknown",
health_latency_ms: rest.health_latency_ms ?? null,
tags: tags_json ? tags_json.split(",").map((t) => t.trim()).filter(Boolean) : []
};
});
} }
function findDomainByZoneName(db, zoneName) { function findDomainByZoneName(db, zoneName) {
const rows = db.all(sql2` const rows = db.all(sql2`
@@ -525,14 +632,18 @@ function createDomain(db, groupId, zoneName, cfZoneId) {
}).returning({ id: domains.id }).get().id; }).returning({ id: domains.id }).get().id;
return getDomain(db, id); return getDomain(db, id);
} }
function updateDomain(db, id, groupId, status, certMonitoring) { function updateDomain(db, id, patch) {
const existing = getDomain(db, id);
const updates = { const updates = {
group_id: groupId, group_id: patch.group_id !== void 0 ? patch.group_id : existing.group_id,
status, status: patch.status !== void 0 ? patch.status : existing.status,
updated_at: sql2`datetime('now')` updated_at: sql2`datetime('now')`
}; };
if (certMonitoring !== void 0) { if (patch.cert_monitoring !== void 0) {
updates.cert_monitoring = certMonitoring; updates.cert_monitoring = patch.cert_monitoring;
}
if (patch.environment !== void 0) {
updates.environment = patch.environment;
} }
const result = db.update(domains).set(updates).where(eq2(domains.id, id)).run(); const result = db.update(domains).set(updates).where(eq2(domains.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`domain ${id}`); if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
@@ -1320,6 +1431,115 @@ function listHealthCheckTargets(db) {
...groupInheritedCnameBindingTargets ...groupInheritedCnameBindingTargets
]; ];
} }
function listDomainTags(db, domainId) {
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq2(domainTags.domain_id, domainId)).all().map((r) => r.tag);
}
function setDomainTags(db, domainId, tags) {
db.delete(domainTags).where(eq2(domainTags.domain_id, domainId)).run();
const unique = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
for (const tag of unique) {
db.insert(domainTags).values({ domain_id: domainId, tag }).run();
}
}
function addDomainTags(db, domainId, tags) {
const unique = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
for (const tag of unique) {
db.run(sql2`
INSERT INTO domain_tags (domain_id, tag)
VALUES (${domainId}, ${tag})
ON CONFLICT(domain_id, tag) DO NOTHING
`);
}
}
function listDomainMonitors(db, domainId) {
return db.select().from(domainMonitors).where(eq2(domainMonitors.domain_id, domainId)).orderBy(asc(domainMonitors.id)).all();
}
function listEnabledDomainMonitors(db) {
return db.select().from(domainMonitors).where(eq2(domainMonitors.enabled, true)).all();
}
function getDomainMonitor(db, id) {
const row = db.select().from(domainMonitors).where(eq2(domainMonitors.id, id)).get();
if (!row) throw new NotFoundError(`domain_monitor ${id}`);
return row;
}
function createDomainMonitor(db, domainId, input) {
const id = db.insert(domainMonitors).values({
domain_id: domainId,
hostname: input.hostname.trim(),
type: input.type,
enabled: input.enabled ?? true,
interval_sec: input.interval_sec ?? 60,
timeout_ms: input.timeout_ms ?? 5e3,
path: input.path ?? null,
expected_status: input.expected_status ?? null
}).returning({ id: domainMonitors.id }).get().id;
return getDomainMonitor(db, id);
}
function deleteDomainMonitor(db, id) {
const result = db.delete(domainMonitors).where(eq2(domainMonitors.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`domain_monitor ${id}`);
}
function updateDomainMonitorResult(db, monitorId, status, latencyMs, error) {
db.update(domainMonitors).set({
last_status: status,
last_latency_ms: latencyMs,
last_checked_at: sql2`datetime('now')`,
last_error: error,
updated_at: sql2`datetime('now')`
}).where(eq2(domainMonitors.id, monitorId)).run();
db.insert(domainMonitorResults).values({
monitor_id: monitorId,
status,
latency_ms: latencyMs,
error
}).run();
db.run(sql2`
DELETE FROM domain_monitor_results
WHERE monitor_id = ${monitorId}
AND id NOT IN (
SELECT id FROM domain_monitor_results
WHERE monitor_id = ${monitorId}
ORDER BY checked_at DESC, id DESC
LIMIT 100
)
`);
}
function listDomainMonitorResults(db, monitorId, limit = 50) {
return db.all(sql2`
SELECT id, monitor_id, status, latency_ms, error, checked_at
FROM domain_monitor_results
WHERE monitor_id = ${monitorId}
ORDER BY checked_at DESC, id DESC
LIMIT ${limit}
`);
}
function listDomainMonitorResultsForDomain(db, domainId, limit = 50) {
return db.all(sql2`
SELECT r.id, r.monitor_id, m.hostname, m.type, r.status, r.latency_ms, r.error, r.checked_at
FROM domain_monitor_results r
JOIN domain_monitors m ON m.id = r.monitor_id
WHERE m.domain_id = ${domainId}
ORDER BY r.checked_at DESC, r.id DESC
LIMIT ${limit}
`);
}
function insertNotificationLog(db, kind, refType, refId, title, message) {
db.insert(notificationLog).values({
kind,
ref_type: refType,
ref_id: refId,
title,
message
}).run();
}
function listNotificationLog(db, limit = 50) {
return db.all(sql2`
SELECT id, kind, ref_type, ref_id, title, message, created_at
FROM notification_log
ORDER BY created_at DESC, id DESC
LIMIT ${limit}
`);
}
export { export {
ConflictError, ConflictError,
NotFoundError, NotFoundError,
@@ -1328,6 +1548,9 @@ export {
createDb, createDb,
createMemoryDb, createMemoryDb,
dnsRecords, dnsRecords,
domainMonitorResults,
domainMonitors,
domainTags,
domains, domains,
getAppSettings, getAppSettings,
getAppSettingsSecrets, getAppSettingsSecrets,
@@ -1335,6 +1558,7 @@ export {
groups, groups,
healthCheck, healthCheck,
ipHealthStatus, ipHealthStatus,
notificationLog,
repos_exports as repos, repos_exports as repos,
resolveDatabasePath, resolveDatabasePath,
runMigrations, runMigrations,
@@ -0,0 +1,54 @@
-- Domain environment + tags + monitors + notification log
ALTER TABLE domains ADD COLUMN environment TEXT;
CREATE TABLE IF NOT EXISTS domain_tags (
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (domain_id, tag)
);
CREATE TABLE IF NOT EXISTS domain_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'http',
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 60,
timeout_ms INTEGER NOT NULL DEFAULT 5000,
path TEXT,
expected_status INTEGER,
last_status TEXT NOT NULL DEFAULT 'unknown',
last_latency_ms INTEGER,
last_checked_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_domain_monitors_domain
ON domain_monitors(domain_id);
CREATE TABLE IF NOT EXISTS domain_monitor_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER NOT NULL REFERENCES domain_monitors(id) ON DELETE CASCADE,
status TEXT NOT NULL,
latency_ms INTEGER,
error TEXT,
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_domain_monitor_results_monitor
ON domain_monitor_results(monitor_id, checked_at DESC);
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
ref_type TEXT NOT NULL,
ref_id INTEGER,
title TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notification_log_created
ON notification_log(created_at DESC);
+315 -15
View File
@@ -24,9 +24,13 @@ import { NotFoundError } from "./errors.js";
import { import {
certificates, certificates,
dnsRecords, dnsRecords,
domainMonitorResults,
domainMonitors,
domainTags,
domains, domains,
groups, groups,
ipHealthStatus, ipHealthStatus,
notificationLog,
serviceBindingIps, serviceBindingIps,
serviceBindingRecords, serviceBindingRecords,
serviceBindings, serviceBindings,
@@ -119,14 +123,65 @@ export function listDomainsEnriched(db: Db, groupId?: number): DomainListItem[]
const base = groupId != null const base = groupId != null
? sql`WHERE d.group_id = ${groupId}` ? sql`WHERE d.group_id = ${groupId}`
: sql``; : sql``;
return db.all<DomainListItem>(sql` const rows = db.all<
Omit<DomainListItem, "tags"> & { tags_json: string | null }
>(sql`
SELECT d.*, g.name AS group_name, SELECT d.*, g.name AS group_name,
(SELECT COUNT(*) FROM service_bindings sb WHERE sb.domain_id = d.id) AS service_count (SELECT COUNT(*) FROM service_bindings sb WHERE sb.domain_id = d.id) AS service_count,
COALESCE((
SELECT CASE
WHEN MAX(CASE
WHEN ihs.status = 'down' THEN 3
WHEN ihs.status = 'degraded' THEN 2
WHEN ihs.status = 'up' THEN 1
ELSE 0
END) = 3 THEN 'down'
WHEN MAX(CASE
WHEN ihs.status = 'down' THEN 3
WHEN ihs.status = 'degraded' THEN 2
WHEN ihs.status = 'up' THEN 1
ELSE 0
END) = 2 THEN 'degraded'
WHEN MAX(CASE
WHEN ihs.status = 'down' THEN 3
WHEN ihs.status = 'degraded' THEN 2
WHEN ihs.status = 'up' THEN 1
ELSE 0
END) = 1 THEN 'up'
ELSE 'unknown'
END
FROM ip_health_status ihs
INNER JOIN service_bindings sb ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.domain_id = d.id
), 'unknown') AS health_status,
(
SELECT MAX(ihs.latency_ms)
FROM ip_health_status ihs
INNER JOIN service_bindings sb ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.domain_id = d.id
) AS health_latency_ms,
(
SELECT GROUP_CONCAT(dt.tag, ',')
FROM domain_tags dt
WHERE dt.domain_id = d.id
) AS tags_json
FROM domains d FROM domains d
LEFT JOIN groups g ON g.id = d.group_id LEFT JOIN groups g ON g.id = d.group_id
${base} ${base}
ORDER BY d.zone_name ASC ORDER BY d.zone_name ASC
`); `);
return rows.map((row) => {
const { tags_json, ...rest } = row;
return {
...rest,
environment: (rest.environment as DomainListItem["environment"]) ?? null,
health_status: rest.health_status ?? "unknown",
health_latency_ms: rest.health_latency_ms ?? null,
tags: tags_json
? tags_json.split(",").map((t) => t.trim()).filter(Boolean)
: [],
};
});
} }
export function findDomainByZoneName(db: Db, zoneName: string): Domain | null { export function findDomainByZoneName(db: Db, zoneName: string): Domain | null {
@@ -163,22 +218,24 @@ export function createDomain(
export function updateDomain( export function updateDomain(
db: Db, db: Db,
id: number, id: number,
groupId: number | null, patch: {
status: string, group_id?: number | null;
certMonitoring?: string, status?: string;
): Domain {
const updates: {
group_id: number | null;
status: string;
cert_monitoring?: string; cert_monitoring?: string;
updated_at: ReturnType<typeof sql>; environment?: string | null;
} = { },
group_id: groupId, ): Domain {
status, const existing = getDomain(db, id);
const updates: Record<string, unknown> = {
group_id: patch.group_id !== undefined ? patch.group_id : existing.group_id,
status: patch.status !== undefined ? patch.status : existing.status,
updated_at: sql`datetime('now')`, updated_at: sql`datetime('now')`,
}; };
if (certMonitoring !== undefined) { if (patch.cert_monitoring !== undefined) {
updates.cert_monitoring = certMonitoring; updates.cert_monitoring = patch.cert_monitoring;
}
if (patch.environment !== undefined) {
updates.environment = patch.environment;
} }
const result = db const result = db
.update(domains) .update(domains)
@@ -1595,3 +1652,246 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
...groupInheritedCnameBindingTargets, ...groupInheritedCnameBindingTargets,
]; ];
} }
// --- Domain tags ---
export function listDomainTags(db: Db, domainId: number): string[] {
return db
.select({ tag: domainTags.tag })
.from(domainTags)
.where(eq(domainTags.domain_id, domainId))
.all()
.map((r) => r.tag);
}
export function setDomainTags(db: Db, domainId: number, tags: string[]): void {
db.delete(domainTags).where(eq(domainTags.domain_id, domainId)).run();
const unique = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
for (const tag of unique) {
db.insert(domainTags).values({ domain_id: domainId, tag }).run();
}
}
export function addDomainTags(db: Db, domainId: number, tags: string[]): void {
const unique = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
for (const tag of unique) {
db.run(sql`
INSERT INTO domain_tags (domain_id, tag)
VALUES (${domainId}, ${tag})
ON CONFLICT(domain_id, tag) DO NOTHING
`);
}
}
// --- Domain monitors ---
export interface DomainMonitorRow {
id: number;
domain_id: number;
hostname: string;
type: string;
enabled: boolean;
interval_sec: number;
timeout_ms: number;
path: string | null;
expected_status: number | null;
last_status: string;
last_latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
created_at: string;
updated_at: string;
}
export function listDomainMonitors(
db: Db,
domainId: number,
): DomainMonitorRow[] {
return db
.select()
.from(domainMonitors)
.where(eq(domainMonitors.domain_id, domainId))
.orderBy(asc(domainMonitors.id))
.all() as DomainMonitorRow[];
}
export function listEnabledDomainMonitors(db: Db): DomainMonitorRow[] {
return db
.select()
.from(domainMonitors)
.where(eq(domainMonitors.enabled, true))
.all() as DomainMonitorRow[];
}
export function getDomainMonitor(db: Db, id: number): DomainMonitorRow {
const row = db
.select()
.from(domainMonitors)
.where(eq(domainMonitors.id, id))
.get();
if (!row) throw new NotFoundError(`domain_monitor ${id}`);
return row as DomainMonitorRow;
}
export function createDomainMonitor(
db: Db,
domainId: number,
input: {
hostname: string;
type: string;
enabled?: boolean;
interval_sec?: number;
timeout_ms?: number;
path?: string | null;
expected_status?: number | null;
},
): DomainMonitorRow {
const id = db
.insert(domainMonitors)
.values({
domain_id: domainId,
hostname: input.hostname.trim(),
type: input.type,
enabled: input.enabled ?? true,
interval_sec: input.interval_sec ?? 60,
timeout_ms: input.timeout_ms ?? 5000,
path: input.path ?? null,
expected_status: input.expected_status ?? null,
})
.returning({ id: domainMonitors.id })
.get()!.id;
return getDomainMonitor(db, id);
}
export function deleteDomainMonitor(db: Db, id: number): void {
const result = db
.delete(domainMonitors)
.where(eq(domainMonitors.id, id))
.run();
if (result.changes === 0) throw new NotFoundError(`domain_monitor ${id}`);
}
export function updateDomainMonitorResult(
db: Db,
monitorId: number,
status: string,
latencyMs: number | null,
error: string | null,
): void {
db.update(domainMonitors)
.set({
last_status: status,
last_latency_ms: latencyMs,
last_checked_at: sql`datetime('now')`,
last_error: error,
updated_at: sql`datetime('now')`,
})
.where(eq(domainMonitors.id, monitorId))
.run();
db.insert(domainMonitorResults)
.values({
monitor_id: monitorId,
status,
latency_ms: latencyMs,
error,
})
.run();
// keep last 100 results per monitor
db.run(sql`
DELETE FROM domain_monitor_results
WHERE monitor_id = ${monitorId}
AND id NOT IN (
SELECT id FROM domain_monitor_results
WHERE monitor_id = ${monitorId}
ORDER BY checked_at DESC, id DESC
LIMIT 100
)
`);
}
export function listDomainMonitorResults(
db: Db,
monitorId: number,
limit = 50,
): {
id: number;
monitor_id: number;
status: string;
latency_ms: number | null;
error: string | null;
checked_at: string;
}[] {
return db.all(sql`
SELECT id, monitor_id, status, latency_ms, error, checked_at
FROM domain_monitor_results
WHERE monitor_id = ${monitorId}
ORDER BY checked_at DESC, id DESC
LIMIT ${limit}
`);
}
export function listDomainMonitorResultsForDomain(
db: Db,
domainId: number,
limit = 50,
): {
id: number;
monitor_id: number;
hostname: string;
type: string;
status: string;
latency_ms: number | null;
error: string | null;
checked_at: string;
}[] {
return db.all(sql`
SELECT r.id, r.monitor_id, m.hostname, m.type, r.status, r.latency_ms, r.error, r.checked_at
FROM domain_monitor_results r
JOIN domain_monitors m ON m.id = r.monitor_id
WHERE m.domain_id = ${domainId}
ORDER BY r.checked_at DESC, r.id DESC
LIMIT ${limit}
`);
}
// --- Notification log ---
export function insertNotificationLog(
db: Db,
kind: string,
refType: string,
refId: number | null,
title: string,
message: string,
): void {
db.insert(notificationLog)
.values({
kind,
ref_type: refType,
ref_id: refId,
title,
message,
})
.run();
}
export function listNotificationLog(
db: Db,
limit = 50,
): {
id: number;
kind: string;
ref_type: string;
ref_id: number | null;
title: string;
message: string;
created_at: string;
}[] {
return db.all(sql`
SELECT id, kind, ref_type, ref_id, title, message, created_at
FROM notification_log
ORDER BY created_at DESC, id DESC
LIMIT ${limit}
`);
}
+65
View File
@@ -77,6 +77,7 @@ export const domains = sqliteTable("domains", {
cf_zone_id: text("cf_zone_id").notNull(), cf_zone_id: text("cf_zone_id").notNull(),
status: text("status").notNull().default("active"), status: text("status").notNull().default("active"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"), cert_monitoring: text("cert_monitoring").notNull().default("auto"),
environment: text("environment"),
last_synced_at: text("last_synced_at"), last_synced_at: text("last_synced_at"),
created_at: text("created_at") created_at: text("created_at")
.notNull() .notNull()
@@ -287,6 +288,66 @@ export const appSettings = sqliteTable("app_settings", {
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
}); });
export const domainTags = sqliteTable(
"domain_tags",
{
domain_id: integer("domain_id")
.notNull()
.references(() => domains.id, { onDelete: "cascade" }),
tag: text("tag").notNull(),
},
(t) => [primaryKey({ columns: [t.domain_id, t.tag] })],
);
export const domainMonitors = sqliteTable("domain_monitors", {
id: integer("id").primaryKey({ autoIncrement: true }),
domain_id: integer("domain_id")
.notNull()
.references(() => domains.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
type: text("type").notNull().default("http"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
interval_sec: integer("interval_sec").notNull().default(60),
timeout_ms: integer("timeout_ms").notNull().default(5000),
path: text("path"),
expected_status: integer("expected_status"),
last_status: text("last_status").notNull().default("unknown"),
last_latency_ms: integer("last_latency_ms"),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
});
export const domainMonitorResults = sqliteTable("domain_monitor_results", {
id: integer("id").primaryKey({ autoIncrement: true }),
monitor_id: integer("monitor_id")
.notNull()
.references(() => domainMonitors.id, { onDelete: "cascade" }),
status: text("status").notNull(),
latency_ms: integer("latency_ms"),
error: text("error"),
checked_at: text("checked_at")
.notNull()
.default(sql`datetime('now')`),
});
export const notificationLog = sqliteTable("notification_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
kind: text("kind").notNull(),
ref_type: text("ref_type").notNull(),
ref_id: integer("ref_id"),
title: text("title").notNull(),
message: text("message").notNull(),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
});
export const schema = { export const schema = {
groups, groups,
services, services,
@@ -303,4 +364,8 @@ export const schema = {
syncJobs, syncJobs,
ipHealthStatus, ipHealthStatus,
appSettings, appSettings,
domainTags,
domainMonitors,
domainMonitorResults,
notificationLog,
}; };
+156 -14
View File
@@ -152,7 +152,7 @@ interface JwtClaims {
exp: number; exp: number;
} }
type LbMode = "round_robin" | "failover" | "weighted"; type LbMode = "round_robin" | "failover" | "weighted";
type HealthCheckType = "tcp" | "http"; type HealthCheckType = "tcp" | "http" | "ping" | "dns";
type IpHealthState = "up" | "down" | "degraded" | "unknown"; type IpHealthState = "up" | "down" | "degraded" | "unknown";
type HealthCheckScope = "binding" | "group"; type HealthCheckScope = "binding" | "group";
interface IpHealthStatus { interface IpHealthStatus {
@@ -221,7 +221,21 @@ declare const lbModeSchema: z.ZodEnum<{
declare const healthCheckTypeSchema: z.ZodEnum<{ declare const healthCheckTypeSchema: z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>; }>;
declare const domainEnvironmentSchema: z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>;
type DomainEnvironment = z.infer<typeof domainEnvironmentSchema>;
declare const domainMonitorTypeSchema: z.ZodEnum<{
http: "http";
ping: "ping";
dns: "dns";
}>;
type DomainMonitorType = z.infer<typeof domainMonitorTypeSchema>;
declare const ipHealthStateSchema: z.ZodEnum<{ declare const ipHealthStateSchema: z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
up: "up"; up: "up";
@@ -294,6 +308,8 @@ declare const serviceGroupSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -340,6 +356,8 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -360,7 +378,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
fqdn: string; fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -377,7 +395,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
record_type: "A" | "CNAME"; record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -427,6 +445,8 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -447,7 +467,7 @@ declare const serviceViewSchema: z.ZodObject<{
fqdn: string; fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -464,7 +484,7 @@ declare const serviceViewSchema: z.ZodObject<{
record_type: "A" | "CNAME"; record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -500,6 +520,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -545,6 +567,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -565,7 +589,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
fqdn: string; fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -582,7 +606,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
record_type: "A" | "CNAME"; record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -620,6 +644,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -665,6 +691,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -685,7 +713,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
fqdn: string; fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -702,7 +730,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
record_type: "A" | "CNAME"; record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -754,6 +782,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -774,7 +804,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
fqdn: string; fqdn: string;
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -791,7 +821,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
record_type: "A" | "CNAME"; record_type: "A" | "CNAME";
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -817,6 +847,11 @@ declare const domainSchema: z.ZodObject<{
required: "required"; required: "required";
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
environment: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>>;
last_synced_at: z.ZodNullable<z.ZodString>; last_synced_at: z.ZodNullable<z.ZodString>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
@@ -832,11 +867,24 @@ declare const domainListItemSchema: z.ZodObject<{
required: "required"; required: "required";
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
environment: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>>;
last_synced_at: z.ZodNullable<z.ZodString>; last_synced_at: z.ZodNullable<z.ZodString>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
group_name: z.ZodNullable<z.ZodString>; group_name: z.ZodNullable<z.ZodString>;
service_count: z.ZodNumber; service_count: z.ZodNumber;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
@@ -862,6 +910,8 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{ health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodNullable<z.ZodNumber>; health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>; health_check_path: z.ZodNullable<z.ZodString>;
@@ -888,7 +938,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
target_ip: string | null; target_ip: string | null;
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -911,7 +961,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
target_ip: string | null; target_ip: string | null;
lb_mode: "round_robin" | "failover" | "weighted"; lb_mode: "round_robin" | "failover" | "weighted";
health_check_enabled: boolean; health_check_enabled: boolean;
health_check_type: "tcp" | "http"; health_check_type: "tcp" | "http" | "ping" | "dns";
health_check_port: number | null; health_check_port: number | null;
health_check_path: string | null; health_check_path: string | null;
health_check_expected_status: number | null; health_check_expected_status: number | null;
@@ -973,6 +1023,8 @@ declare const healthCheckConfigSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{ health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>; health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>; health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -997,6 +1049,8 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{ health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>; health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>; health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1081,7 +1135,89 @@ declare const updateDomainSchema: z.ZodObject<{
required: "required"; required: "required";
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
environment: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>;
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const bulkUpdateDomainsSchema: z.ZodObject<{
ids: z.ZodArray<z.ZodNumber>;
group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
environment: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>;
tags_add: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
type BulkUpdateDomainsInput = z.infer<typeof bulkUpdateDomainsSchema>;
declare const domainMonitorSchema: z.ZodObject<{
id: z.ZodNumber;
domain_id: z.ZodNumber;
hostname: z.ZodString;
type: z.ZodEnum<{
http: "http";
ping: "ping";
dns: "dns";
}>;
enabled: z.ZodCoercedBoolean<unknown>;
interval_sec: z.ZodNumber;
timeout_ms: z.ZodNumber;
path: z.ZodNullable<z.ZodString>;
expected_status: z.ZodNullable<z.ZodNumber>;
last_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>>;
last_latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
type DomainMonitor = z.infer<typeof domainMonitorSchema>;
declare const createDomainMonitorSchema: z.ZodObject<{
hostname: z.ZodString;
type: z.ZodEnum<{
http: "http";
ping: "ping";
dns: "dns";
}>;
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
interval_sec: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
}, z.core.$strip>;
type CreateDomainMonitorInput = z.infer<typeof createDomainMonitorSchema>;
declare const domainMonitorResultSchema: z.ZodObject<{
id: z.ZodNumber;
monitor_id: z.ZodNumber;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
error: z.ZodNullable<z.ZodString>;
checked_at: z.ZodString;
}, z.core.$strip>;
type DomainMonitorResult = z.infer<typeof domainMonitorResultSchema>;
declare const notificationLogSchema: z.ZodObject<{
id: z.ZodNumber;
kind: z.ZodString;
ref_type: z.ZodString;
ref_id: z.ZodNullable<z.ZodNumber>;
title: z.ZodString;
message: z.ZodString;
created_at: z.ZodString;
}, z.core.$strip>;
type NotificationLog = z.infer<typeof notificationLogSchema>;
type CreateSubdomainInput = z.infer<typeof createSubdomainSchema>; type CreateSubdomainInput = z.infer<typeof createSubdomainSchema>;
type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema>; type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema>;
type UpdateDomainInput = z.infer<typeof updateDomainSchema>; type UpdateDomainInput = z.infer<typeof updateDomainSchema>;
@@ -1100,6 +1236,8 @@ declare const updateServiceConfigSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{ health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>; health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>; health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1124,6 +1262,8 @@ declare const createServiceGroupSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{ health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>; health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>; health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1151,6 +1291,8 @@ declare const updateServiceGroupSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{ health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp"; tcp: "tcp";
http: "http"; http: "http";
ping: "ping";
dns: "dns";
}>>; }>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>; health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>; health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1300,4 +1442,4 @@ declare const vpsTrackerEventSchema: z.ZodObject<{
}, z.core.$strip>; }, z.core.$strip>;
type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>; type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;
export { type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; export { type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+67 -3
View File
@@ -161,7 +161,9 @@ function bindingToFqdn(binding) {
import { z } from "zod"; import { z } from "zod";
var certMonitoringSchema = z.enum(["auto", "required", "skipped"]); var certMonitoringSchema = z.enum(["auto", "required", "skipped"]);
var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]); var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]);
var healthCheckTypeSchema = z.enum(["tcp", "http"]); var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]);
var domainEnvironmentSchema = z.enum(["prod", "staging", "dev"]);
var domainMonitorTypeSchema = z.enum(["http", "ping", "dns"]);
var ipHealthStateSchema = z.enum(["up", "down", "degraded", "unknown"]); var ipHealthStateSchema = z.enum(["up", "down", "degraded", "unknown"]);
var healthCheckScopeSchema = z.enum(["binding", "group"]); var healthCheckScopeSchema = z.enum(["binding", "group"]);
var ipHealthStatusSchema = z.object({ var ipHealthStatusSchema = z.object({
@@ -271,13 +273,17 @@ var domainSchema = z.object({
cf_zone_id: z.string(), cf_zone_id: z.string(),
status: z.string(), status: z.string(),
cert_monitoring: certMonitoringSchema.default("auto"), cert_monitoring: certMonitoringSchema.default("auto"),
environment: domainEnvironmentSchema.nullable().optional().default(null),
last_synced_at: z.string().nullable(), last_synced_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string() updated_at: z.string()
}); });
var domainListItemSchema = domainSchema.extend({ var domainListItemSchema = domainSchema.extend({
group_name: z.string().nullable(), group_name: z.string().nullable(),
service_count: z.number() service_count: z.number(),
health_status: ipHealthStateSchema.default("unknown"),
health_latency_ms: z.number().nullable().default(null),
tags: z.array(z.string()).default([])
}); });
var serviceBindingSchema = z.object({ var serviceBindingSchema = z.object({
id: z.number(), id: z.number(),
@@ -440,7 +446,58 @@ var updateSubdomainSchema = z.object({
var updateDomainSchema = z.object({ var updateDomainSchema = z.object({
group_id: z.number().nullable().optional(), group_id: z.number().nullable().optional(),
status: z.string().optional(), status: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional() cert_monitoring: certMonitoringSchema.optional(),
environment: domainEnvironmentSchema.nullable().optional(),
tags: z.array(z.string().min(1).max(64)).max(20).optional()
});
var bulkUpdateDomainsSchema = z.object({
ids: z.array(z.number().int().positive()).min(1),
group_id: z.number().nullable().optional(),
environment: domainEnvironmentSchema.nullable().optional(),
tags_add: z.array(z.string().min(1).max(64)).max(20).optional()
});
var domainMonitorSchema = z.object({
id: z.number(),
domain_id: z.number(),
hostname: z.string(),
type: domainMonitorTypeSchema,
enabled: z.coerce.boolean(),
interval_sec: z.number(),
timeout_ms: z.number(),
path: z.string().nullable(),
expected_status: z.number().nullable(),
last_status: ipHealthStateSchema.default("unknown"),
last_latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
created_at: z.string(),
updated_at: z.string()
});
var createDomainMonitorSchema = z.object({
hostname: z.string().min(1),
type: domainMonitorTypeSchema,
enabled: z.boolean().optional().default(true),
interval_sec: z.number().int().min(10).max(3600).optional().default(60),
timeout_ms: z.number().int().min(500).max(3e4).optional().default(5e3),
path: z.string().nullable().optional(),
expected_status: z.number().int().min(100).max(599).nullable().optional()
});
var domainMonitorResultSchema = z.object({
id: z.number(),
monitor_id: z.number(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
error: z.string().nullable(),
checked_at: z.string()
});
var notificationLogSchema = z.object({
id: z.number(),
kind: z.string(),
ref_type: z.string(),
ref_id: z.number().nullable(),
title: z.string(),
message: z.string(),
created_at: z.string()
}); });
var updateServiceConfigSchema = z.object({ var updateServiceConfigSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435").optional(), name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435").optional(),
@@ -555,12 +612,14 @@ export {
appSwitcherEntrySchema, appSwitcherEntrySchema,
appSwitcherIconSchema, appSwitcherIconSchema,
bindingToFqdn, bindingToFqdn,
bulkUpdateDomainsSchema,
certMonitoringSchema, certMonitoringSchema,
certStatusFromExpiry, certStatusFromExpiry,
certificateSchema, certificateSchema,
cfdmBindingSyncItemSchema, cfdmBindingSyncItemSchema,
cfdmSyncBindingsBodySchema, cfdmSyncBindingsBodySchema,
createDnsRecordSchema, createDnsRecordSchema,
createDomainMonitorSchema,
createDomainSchema, createDomainSchema,
createGroupSchema, createGroupSchema,
createServiceBindingSchema, createServiceBindingSchema,
@@ -571,7 +630,11 @@ export {
dnsNameToSubdomainLabel, dnsNameToSubdomainLabel,
dnsRecordNamesMatch, dnsRecordNamesMatch,
dnsRecordSchema, dnsRecordSchema,
domainEnvironmentSchema,
domainListItemSchema, domainListItemSchema,
domainMonitorResultSchema,
domainMonitorSchema,
domainMonitorTypeSchema,
domainSchema, domainSchema,
fqdnToDisplay, fqdnToDisplay,
groupSchema, groupSchema,
@@ -586,6 +649,7 @@ export {
lbModeSchema, lbModeSchema,
loginSchema, loginSchema,
normalizeDnsRecordName, normalizeDnsRecordName,
notificationLogSchema,
parseFqdn, parseFqdn,
reorderServicesSchema, reorderServicesSchema,
serviceBindingSchema, serviceBindingSchema,
+77 -1
View File
@@ -7,9 +7,15 @@ export type CertMonitoring = z.infer<typeof certMonitoringSchema>
export const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted']) export const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
export type LbMode = z.infer<typeof lbModeSchema> export type LbMode = z.infer<typeof lbModeSchema>
export const healthCheckTypeSchema = z.enum(['tcp', 'http']) export const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
export type HealthCheckType = z.infer<typeof healthCheckTypeSchema> export type HealthCheckType = z.infer<typeof healthCheckTypeSchema>
export const domainEnvironmentSchema = z.enum(['prod', 'staging', 'dev'])
export type DomainEnvironment = z.infer<typeof domainEnvironmentSchema>
export const domainMonitorTypeSchema = z.enum(['http', 'ping', 'dns'])
export type DomainMonitorType = z.infer<typeof domainMonitorTypeSchema>
export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown']) export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown'])
export type IpHealthState = z.infer<typeof ipHealthStateSchema> export type IpHealthState = z.infer<typeof ipHealthStateSchema>
@@ -144,6 +150,7 @@ export const domainSchema = z.object({
cf_zone_id: z.string(), cf_zone_id: z.string(),
status: z.string(), status: z.string(),
cert_monitoring: certMonitoringSchema.default('auto'), cert_monitoring: certMonitoringSchema.default('auto'),
environment: domainEnvironmentSchema.nullable().optional().default(null),
last_synced_at: z.string().nullable(), last_synced_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
@@ -152,6 +159,9 @@ export const domainSchema = z.object({
export const domainListItemSchema = domainSchema.extend({ export const domainListItemSchema = domainSchema.extend({
group_name: z.string().nullable(), group_name: z.string().nullable(),
service_count: z.number(), service_count: z.number(),
health_status: ipHealthStateSchema.default('unknown'),
health_latency_ms: z.number().nullable().default(null),
tags: z.array(z.string()).default([]),
}) })
export const serviceBindingSchema = z export const serviceBindingSchema = z
@@ -361,8 +371,74 @@ export const updateDomainSchema = z.object({
group_id: z.number().nullable().optional(), group_id: z.number().nullable().optional(),
status: z.string().optional(), status: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional(), cert_monitoring: certMonitoringSchema.optional(),
environment: domainEnvironmentSchema.nullable().optional(),
tags: z.array(z.string().min(1).max(64)).max(20).optional(),
}) })
export const bulkUpdateDomainsSchema = z.object({
ids: z.array(z.number().int().positive()).min(1),
group_id: z.number().nullable().optional(),
environment: domainEnvironmentSchema.nullable().optional(),
tags_add: z.array(z.string().min(1).max(64)).max(20).optional(),
})
export type BulkUpdateDomainsInput = z.infer<typeof bulkUpdateDomainsSchema>
export const domainMonitorSchema = z.object({
id: z.number(),
domain_id: z.number(),
hostname: z.string(),
type: domainMonitorTypeSchema,
enabled: z.coerce.boolean(),
interval_sec: z.number(),
timeout_ms: z.number(),
path: z.string().nullable(),
expected_status: z.number().nullable(),
last_status: ipHealthStateSchema.default('unknown'),
last_latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
export type DomainMonitor = z.infer<typeof domainMonitorSchema>
export const createDomainMonitorSchema = z.object({
hostname: z.string().min(1),
type: domainMonitorTypeSchema,
enabled: z.boolean().optional().default(true),
interval_sec: z.number().int().min(10).max(3600).optional().default(60),
timeout_ms: z.number().int().min(500).max(30000).optional().default(5000),
path: z.string().nullable().optional(),
expected_status: z.number().int().min(100).max(599).nullable().optional(),
})
export type CreateDomainMonitorInput = z.infer<typeof createDomainMonitorSchema>
export const domainMonitorResultSchema = z.object({
id: z.number(),
monitor_id: z.number(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
error: z.string().nullable(),
checked_at: z.string(),
})
export type DomainMonitorResult = z.infer<typeof domainMonitorResultSchema>
export const notificationLogSchema = z.object({
id: z.number(),
kind: z.string(),
ref_type: z.string(),
ref_id: z.number().nullable(),
title: z.string(),
message: z.string(),
created_at: z.string(),
})
export type NotificationLog = z.infer<typeof notificationLogSchema>
export type CreateSubdomainInput = z.infer<typeof createSubdomainSchema> export type CreateSubdomainInput = z.infer<typeof createSubdomainSchema>
export type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema> export type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema>
export type UpdateDomainInput = z.infer<typeof updateDomainSchema> export type UpdateDomainInput = z.infer<typeof updateDomainSchema>
+9 -1
View File
@@ -55,6 +55,7 @@ export interface Domain {
cf_zone_id: string; cf_zone_id: string;
status: string; status: string;
cert_monitoring: string; cert_monitoring: string;
environment: DomainEnvironment | null;
last_synced_at: string | null; last_synced_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -193,6 +194,9 @@ export interface GroupWithStats extends Group {
export interface DomainListItem extends Domain { export interface DomainListItem extends Domain {
group_name: string | null; group_name: string | null;
service_count: number; service_count: number;
health_status: IpHealthState;
health_latency_ms: number | null;
tags: string[];
} }
export interface SyncJob { export interface SyncJob {
@@ -246,7 +250,11 @@ export interface JwtClaims {
export type LbMode = "round_robin" | "failover" | "weighted"; export type LbMode = "round_robin" | "failover" | "weighted";
export type HealthCheckType = "tcp" | "http"; export type HealthCheckType = "tcp" | "http" | "ping" | "dns";
export type DomainEnvironment = "prod" | "staging" | "dev";
export type DomainMonitorType = "http" | "ping" | "dns";
export type IpHealthState = "up" | "down" | "degraded" | "unknown"; export type IpHealthState = "up" | "down" | "degraded" | "unknown";