From 64585ccd4726da8b2ceaee1cfb6e961f45da44ce Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 17 Jul 2026 02:25:12 +0700 Subject: [PATCH] feat(api, web): enhance health check and domain management features - 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 --- apps/api/src/app.ts | 45 +- apps/api/src/routes/domain-monitors.ts | 55 + apps/api/src/routes/domains.ts | 25 +- apps/api/src/routes/health-check.ts | 38 +- apps/api/src/services/domain-service.ts | 48 +- apps/api/src/services/health-check-service.ts | 125 +- apps/api/test/certificates.test.ts | 36 +- .../components/columns/domains-columns.tsx | 347 +++-- .../src/components/domain-bindings-card.tsx | 159 +-- .../domains/domain-availability-panel.tsx | 334 +++++ .../domains/domains-bulk-toolbar.tsx | 130 ++ .../web/src/components/health-check-badge.tsx | 39 +- .../src/components/health/health-timeline.tsx | 85 ++ .../src/components/reui-kit/ops-dashboard.tsx | 2 +- .../src/components/reui-kit/resource-page.tsx | 23 +- apps/web/src/components/status-badge.tsx | 52 +- apps/web/src/hooks/use-domain-health.ts | 32 + apps/web/src/lib/schemas.ts | 15 + apps/web/src/queries/certificates.ts | 25 + apps/web/src/queries/dns.ts | 19 + apps/web/src/queries/domain-health.ts | 111 ++ apps/web/src/queries/domains.ts | 86 ++ apps/web/src/queries/groups.ts | 27 + apps/web/src/queries/index.ts | 244 +--- apps/web/src/queries/services.ts | 73 + .../routes/_auth/domains/$domainId/dns.tsx | 11 +- .../routes/_auth/domains/$domainId/index.tsx | 278 +++- apps/web/src/routes/_auth/domains/index.tsx | 56 +- apps/web/src/routes/_auth/index.tsx | 70 +- packages/db/dist/index.d.ts | 1269 ++++++++++++++++- packages/db/dist/index.js | 240 +++- .../014_domain_env_tags_monitors.sql | 54 + packages/db/src/repos.ts | 330 ++++- packages/db/src/schema.ts | 65 + packages/shared/dist/index.d.ts | 170 ++- packages/shared/dist/index.js | 70 +- packages/shared/src/schemas.ts | 78 +- packages/shared/src/types.ts | 10 +- 38 files changed, 4199 insertions(+), 677 deletions(-) create mode 100644 apps/api/src/routes/domain-monitors.ts create mode 100644 apps/web/src/components/domains/domain-availability-panel.tsx create mode 100644 apps/web/src/components/domains/domains-bulk-toolbar.tsx create mode 100644 apps/web/src/components/health/health-timeline.tsx create mode 100644 apps/web/src/hooks/use-domain-health.ts create mode 100644 apps/web/src/queries/certificates.ts create mode 100644 apps/web/src/queries/dns.ts create mode 100644 apps/web/src/queries/domain-health.ts create mode 100644 apps/web/src/queries/domains.ts create mode 100644 apps/web/src/queries/groups.ts create mode 100644 apps/web/src/queries/services.ts create mode 100644 packages/db/migrations/014_domain_env_tags_monitors.sql diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9942099..1c69f9e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { } from "@fastify/type-provider-zod"; import type { AppConfig } from "./config.js"; import { loadConfig } from "./config.js"; +import { repos } from "@cfdm/db"; import authPlugin from "./plugins/auth.js"; import cfClientPlugin from "./plugins/cf-client.js"; import { requireAuth } from "./plugins/auth.js"; @@ -24,6 +25,10 @@ import { subdomainRoutes } from "./routes/subdomains.js"; import { certificateRoutes } from "./routes/certificates.js"; import { syncRoutes } from "./routes/sync.js"; import { healthCheckRoutes } from "./routes/health-check.js"; +import { + domainMonitorRoutes, + notificationRoutes, +} from "./routes/domain-monitors.js"; import { settingsRoutes } from "./routes/settings.js"; import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.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(syncRoutes); await protectedApi.register(healthCheckRoutes); + await protectedApi.register(domainMonitorRoutes); + await protectedApi.register(notificationRoutes); await protectedApi.register(settingsRoutes); }, { prefix: "/api/v1" }, @@ -117,14 +124,31 @@ export async function buildApp(opts: BuildAppOptions = {}) { const healthTask = new AsyncTask( "health-check", async () => { + const thresholds = { + degradedFailures: config.healthDegradedFailures, + downFailures: config.healthDownFailures, + latencyWarnMs: config.healthLatencyWarnMs, + }; const n = await healthCheckService.runAllChecks(app.db, { - thresholds: { - degradedFailures: config.healthDegradedFailures, - downFailures: config.healthDownFailures, - latencyWarnMs: config.healthLatencyWarnMs, - }, - onStatusChange: async (target, _prev, _next) => { + thresholds, + onStatusChange: async (target, prev, next) => { 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( app.db, 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) => { app.log.warn({ err }, "health check failed"); diff --git a/apps/api/src/routes/domain-monitors.ts b/apps/api/src/routes/domain-monitors.ts new file mode 100644 index 0000000..e71beeb --- /dev/null +++ b/apps/api/src/routes/domain-monitors.ts @@ -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); + }); +} diff --git a/apps/api/src/routes/domains.ts b/apps/api/src/routes/domains.ts index 2de1c47..f79e610 100644 --- a/apps/api/src/routes/domains.ts +++ b/apps/api/src/routes/domains.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { updateDomainSchema } from "@cfdm/shared"; +import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared"; import { z } from "zod"; 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) => { const { id } = request.params as { id: string }; return domainService.getDomain(request.server.db, Number(id)); @@ -33,14 +47,7 @@ export async function domainRoutes(app: FastifyInstance) { app.patch("/domains/:id", async (request) => { const { id } = request.params as { id: string }; const body = updateDomainSchema.parse(request.body); - const existing = domainService.getDomain(request.server.db, Number(id)); - 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, - ); + return domainService.updateDomain(request.server.db, Number(id), body); }); app.delete("/domains/:id", async (request) => { diff --git a/apps/api/src/routes/health-check.ts b/apps/api/src/routes/health-check.ts index c3e46e9..1fcb894 100644 --- a/apps/api/src/routes/health-check.ts +++ b/apps/api/src/routes/health-check.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from "fastify"; import { healthStatusQuerySchema } from "@cfdm/shared"; +import { repos } from "@cfdm/db"; import * as healthCheckService from "../services/health-check-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) => { const config = request.server.config; + const thresholds = { + degradedFailures: config.healthDegradedFailures, + downFailures: config.healthDownFailures, + latencyWarnMs: config.healthLatencyWarnMs, + }; const checked = await healthCheckService.runAllChecks(request.server.db, { - thresholds: { - degradedFailures: config.healthDegradedFailures, - downFailures: config.healthDownFailures, - latencyWarnMs: config.healthLatencyWarnMs, - }, - onStatusChange: async (target, _prev, _next) => { + thresholds, + onStatusChange: async (target, prev, next) => { 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( request.server.db, request.server.cf, @@ -30,10 +48,14 @@ export async function healthCheckRoutes(app: FastifyInstance) { target.ref_id, ); } catch { - // best-effort reconcile; ошибки логируются cron-задачей + // best-effort } }, }); - return { checked }; + const monitors = await healthCheckService.runDomainMonitors( + request.server.db, + thresholds, + ); + return { checked, monitors }; }); } diff --git a/apps/api/src/services/domain-service.ts b/apps/api/src/services/domain-service.ts index 21edac6..b065aef 100644 --- a/apps/api/src/services/domain-service.ts +++ b/apps/api/src/services/domain-service.ts @@ -43,11 +43,51 @@ export async function createDomain( export function updateDomain( db: Db, id: number, - groupId: number | null, - status: string, - certMonitoring?: string, + patch: { + group_id?: number | null; + status?: string; + cert_monitoring?: string; + environment?: string | null; + tags?: string[]; + }, ): 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 { diff --git a/apps/api/src/services/health-check-service.ts b/apps/api/src/services/health-check-service.ts index ec39c16..eea8011 100644 --- a/apps/api/src/services/health-check-service.ts +++ b/apps/api/src/services/health-check-service.ts @@ -1,4 +1,5 @@ import { connect } from "node:net"; +import { resolve4, resolve6 } from "node:dns/promises"; import { Agent, fetch as undiciFetch } from "undici"; import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; @@ -68,10 +69,6 @@ async function httpProbe( const pathWithSlash = path.startsWith("/") ? path : `/${path}`; const port = target.port ?? 80; 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 url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`; 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 { + 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 { + 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( target: HealthCheckTarget, ): Promise { @@ -127,6 +165,12 @@ export async function probeTarget( if (target.type === "http") { 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); } @@ -205,6 +249,79 @@ export async function runAllChecks( return targets.length; } +export async function runDomainMonitors( + db: Db, + thresholds: HealthCheckThresholds, +): Promise { + 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( db: Db, scope: "binding" | "group", diff --git a/apps/api/test/certificates.test.ts b/apps/api/test/certificates.test.ts index d19bc12..96ff8e8 100644 --- a/apps/api/test/certificates.test.ts +++ b/apps/api/test/certificates.test.ts @@ -141,13 +141,11 @@ describe("certificates", () => { "required.example.com", "cf-zone-req", ); - repos.updateDomain( - testApp.db, - domain.id, - null, - "active", - CERT_MONITOR_REQUIRED, - ); + repos.updateDomain(testApp.db, domain.id, { + group_id: null, + status: "active", + cert_monitoring: CERT_MONITOR_REQUIRED, + }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), @@ -191,13 +189,11 @@ describe("certificates", () => { CERT_ERROR, "stale", ); - repos.updateDomain( - testApp.db, - domain.id, - null, - "active", - CERT_MONITOR_SKIPPED, - ); + repos.updateDomain(testApp.db, domain.id, { + group_id: null, + status: "active", + cert_monitoring: CERT_MONITOR_SKIPPED, + }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: null, @@ -229,13 +225,11 @@ describe("certificates", () => { "broken.example.com", "cf-zone-broken", ); - repos.updateDomain( - testApp.db, - domain.id, - null, - "active", - CERT_MONITOR_REQUIRED, - ); + repos.updateDomain(testApp.db, domain.id, { + group_id: null, + status: "active", + cert_monitoring: CERT_MONITOR_REQUIRED, + }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: null, diff --git a/apps/web/src/components/columns/domains-columns.tsx b/apps/web/src/components/columns/domains-columns.tsx index b1f84ae..df187ba 100644 --- a/apps/web/src/components/columns/domains-columns.tsx +++ b/apps/web/src/components/columns/domains-columns.tsx @@ -5,9 +5,15 @@ import { MoreHorizontalIcon, SearchIcon } from 'lucide-react' import { Badge } from '@/components/reui/badge' 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 { HealthCheckBadge } from '@/components/health-check-badge' import { StatusBadge } from '@/components/status-badge' import type { DomainListItem } from '@/lib/schemas' +import { formatRelative, sqliteUtcToIso } from '@/lib/format' import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils' import { Button } from '@cfdm/ui/components/button' import { @@ -19,12 +25,18 @@ import { export const DOMAIN_TABS = [ { 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: 'Без группы' }, ] as const 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 return true } @@ -56,6 +68,29 @@ export function useDomainFilterFields( customValueRenderer: (values) => 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], ) @@ -64,123 +99,63 @@ export function useDomainFilterFields( export function domainFilterFieldValue(item: DomainListItem, field: string) { switch (field) { 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': return item.group_id != null ? String(item.group_id) : 'none' + case 'health_status': + return item.health_status ?? 'unknown' + case 'environment': + return item.environment ?? '' default: return '' } } +function EnvChip({ env }: { env: string | null | undefined }) { + if (!env) return null + return ( + + {env} + + ) +} + export function useDomainColumns({ onRequestDelete, isDeleting, + enableSelection = false, }: { onRequestDelete: (domain: DomainListItem) => void isDeleting?: boolean + enableSelection?: boolean }) { const columns = useMemo[]>( - () => [ - { - id: 'zone_name', - accessorKey: 'zone_name', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - }, - { - id: 'group', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const domain = row.original - if (domain.group_id && domain.group_name) { - return ( - - ) - } - return Без группы - }, - }, - { - id: 'service_count', - accessorKey: 'service_count', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - }, - { - id: 'status', - header: 'Статус', - cell: ({ row }) => , - }, - { - id: 'last_synced_at', - accessorKey: 'last_synced_at', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.last_synced_at ?? '—'} - - ), - }, - { - id: 'actions', - header: '', - enableHiding: false, - cell: ({ row }) => ( -
- - } - > - - Действия - - - { + const cols: ColumnDef[] = [] + if (enableSelection) { + cols.push({ + id: 'select', + header: () => , + cell: ({ row }) => , + size: 36, + enableSorting: false, + enableHiding: false, + }) + } + cols.push( + { + id: 'zone_name', + accessorKey: 'zone_name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+
+
+ {(row.original.tags ?? []).length > 0 ? ( +
+ {row.original.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ ), + }, + { + id: 'health', + accessorKey: 'health_status', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + id: 'group', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const domain = row.original + if (domain.group_id && domain.group_name) { + return ( + + ) + } + return Без группы + }, + }, + { + id: 'service_count', + accessorKey: 'service_count', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + id: 'status', + header: 'Зона CF', + cell: ({ row }) => , + }, + { + id: 'last_synced_at', + accessorKey: 'last_synced_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatRelative( + sqliteUtcToIso(row.original.last_synced_at) ?? + row.original.last_synced_at, + )} + + ), + }, + { + id: 'actions', + header: '', + enableHiding: false, + cell: ({ row }) => ( +
+ + + } > - Удалить - - - -
- ), - }, - ], - [isDeleting, onRequestDelete], + + Действия + + + + } + > + Обзор + + + } + > + DNS + + onRequestDelete(row.original)} + > + Удалить + + +
+
+ ), + }, + ) + return cols + }, + [enableSelection, isDeleting, onRequestDelete], ) return { columns } diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-card.tsx index d5b99f7..da835e0 100644 --- a/apps/web/src/components/domain-bindings-card.tsx +++ b/apps/web/src/components/domain-bindings-card.tsx @@ -1,11 +1,10 @@ import { Link } from '@tanstack/react-router' import { Link2Icon } from 'lucide-react' 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 { useHealthRows } from '@/lib/use-aggregated-health' -import { formatDate } from '@/lib/format' -import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas' +import type { ServiceBinding } from '@/lib/schemas' import { AppBadge } from '@/components/app-badge' import { AppButton } from '@/components/app-button' import { @@ -24,57 +23,11 @@ import { AppItemSeparator, AppItemTitle, } from '@/components/app-item' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@cfdm/ui/components/tooltip' -import { cn } from '@cfdm/ui/lib/utils' interface DomainBindingsCardProps { bindings: ServiceBinding[] } -const healthDotClass: Record = { - up: 'bg-success', - degraded: 'bg-warning', - down: 'bg-destructive', - unknown: 'bg-muted-foreground/40', -} - -const healthLabel: Record = { - 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 ( - - - - } - /> - {tooltipParts.join('\n')} - - - ) -} - function HostnameIpsHealth({ bindings, ips, @@ -95,9 +48,18 @@ function HostnameIpsHealth({ {ips.map((ip) => { const row = byIp.get(ip) return ( - - {row ? : null} - {ip} + + {row ? ( + + ) : null} + {ip} ) })} @@ -124,83 +86,58 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { ) : ( - - {entries.map(([hostname, hostnameBindings], index) => { - const services = uniqueServices(hostnameBindings) - const uniqueIps = [ + + {entries.map(([hostname, groupBindings], index) => { + const ips = [ ...new Set( - hostnameBindings - .map((b) => b.target_ip) - .filter((ip): ip is string => Boolean(ip)), + groupBindings.flatMap((b) => + b.target_ips.length > 0 + ? b.target_ips + : b.target_ip + ? [b.target_ip] + : [], + ), ), ] - return (
- - - - - {hostname} - - } - /> - {hostname} - -
- {services.map((name) => ( - {name} - ))} - {uniqueIps.length > 0 && ( - - - - )} - {[ - ...new Set( - hostnameBindings - .map((b) => b.sync_status) - .filter((s): s is string => Boolean(s)), - ), - ].map((status) => ( - - ))} + + + {hostname} +
+ {uniqueServices(groupBindings).join(', ')}
+
- } - > - Сервисы - + + {ips.length} IP +
- {index < entries.length - 1 && } + {index < entries.length - 1 ? : null}
) })} )} - {entries.length > 0 && ( - - }> - Управление привязками - - - )} + + } + > + К сервисам + + ) } diff --git a/apps/web/src/components/domains/domain-availability-panel.tsx b/apps/web/src/components/domains/domain-availability-panel.tsx new file mode 100644 index 0000000..16121b5 --- /dev/null +++ b/apps/web/src/components/domains/domain-availability-panel.tsx @@ -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('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 ( +
+
+

+ Мониторы доступности hostname в зоне (HTTP, Ping, DNS) +

+ runMutation.mutate()} + isLoading={runMutation.isPending} + loadingLabel="Проверка…" + disabled={monitors.length === 0} + > + Запустить проверку + +
+ + + + Новый монитор + + Укажите hostname и тип проверки + + + +
{ + e.preventDefault() + if (!canCreate) return + createMutation.mutate() + }} + > + + + + Hostname + + setHostname(e.target.value)} + autoComplete="off" + /> + + + Тип + + + + + Добавить + + +
+
+ + + + + Мониторы + + Последний статус каждой проверки + + + + void monitorsQuery.refetch()} + skeleton={} + > + {monitors.length === 0 ? ( + + ) : ( + + {monitors.map((monitor, index) => ( +
+ + + + {monitor.hostname} + +
+ + {monitorTypeLabel(monitor.type)} + + {monitor.last_checked_at ? ( + + {formatDate(monitor.last_checked_at)} + + ) : ( + Ещё не проверялся + )} +
+
+ + + + deleteMutation.mutate(monitor.id) + } + disabled={deleteMutation.isPending} + trigger={ + + } + /> + +
+ {index < monitors.length - 1 ? ( + + ) : null} +
+ ))} +
+ )} +
+
+ + + + + История проверок + + Последние результаты мониторов зоны + + + + void resultsQuery.refetch()} + skeleton={} + > + + + + +
+ ) +} diff --git a/apps/web/src/components/domains/domains-bulk-toolbar.tsx b/apps/web/src/components/domains/domains-bulk-toolbar.tsx new file mode 100644 index 0000000..230ce44 --- /dev/null +++ b/apps/web/src/components/domains/domains-bulk-toolbar.tsx @@ -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 ( +
+ + Выбрано: {count} + + + + + + setTag(e.target.value)} + /> + + +
+ ) +} diff --git a/apps/web/src/components/health-check-badge.tsx b/apps/web/src/components/health-check-badge.tsx index 47d3261..3336ce7 100644 --- a/apps/web/src/components/health-check-badge.tsx +++ b/apps/web/src/components/health-check-badge.tsx @@ -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 type { VariantProps } from 'class-variance-authority' import { cn } from '@cfdm/ui/lib/utils' import type { IpHealthStatus } from '@/lib/schemas' import { formatDate } from '@/lib/format' -type BadgeVariant = NonNullable['variant']> +type BadgeVariant = NonNullable['variant']> const healthVariants: Record = { - up: 'success', - degraded: 'secondary', - down: 'destructive', + up: 'success-light', + degraded: 'warning-light', + down: 'destructive-light', unknown: 'outline', } const healthLabels: Record = { up: 'OK', - degraded: 'Деград.', + degraded: 'Slow', down: 'Down', unknown: '—', } +const dotColor: Record = { + up: 'bg-success', + degraded: 'bg-warning', + down: 'bg-destructive', + unknown: 'bg-muted-foreground', +} + interface HealthCheckBadgeProps { status: IpHealthStatus['status'] latencyMs?: number | null lastCheckedAt?: string | null lastError?: string | null title?: string + showLatency?: boolean + size?: 'xs' | 'sm' className?: string } @@ -36,6 +45,8 @@ export function HealthCheckBadge({ lastCheckedAt, lastError, title, + showLatency = false, + size = 'sm', className, }: HealthCheckBadgeProps) { const variant = healthVariants[status] @@ -53,16 +64,24 @@ export function HealthCheckBadge({ + } > - + {label} + {showLatency && latencyMs != null ? ( + {latencyMs} мс + ) : null} {tooltipParts.join('\n')} diff --git a/apps/web/src/components/health/health-timeline.tsx b/apps/web/src/components/health/health-timeline.tsx new file mode 100644 index 0000000..d53b3cb --- /dev/null +++ b/apps/web/src/components/health/health-timeline.tsx @@ -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 ( + + ) + } + + return ( + + {events.map((event, index) => { + const checkedIso = + sqliteUtcToIso(event.checked_at) ?? event.checked_at + return ( + + + + + + {event.hostname ? ( + {event.hostname} + ) : null} + {event.type ? ( + + {event.type} + + ) : null} + + + + {formatRelative(checkedIso)} · {formatDate(checkedIso)} + + + {event.error ? ( + + + {event.error} + + + ) : null} + + ) + })} + + ) +} diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx index 66328c0..ebbc3ac 100644 --- a/apps/web/src/components/reui-kit/ops-dashboard.tsx +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -98,7 +98,7 @@ export function OpsDashboard({ Требуют внимания - Истекающие сертификаты и домены без группы + Проблемы health-check, истекающие сертификаты и домены без группы {queue} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 4d0f9b5..f9d9599 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -67,6 +67,11 @@ export interface ResourcePageProps { emptyState?: { title: string; description?: string; action?: ReactNode } pageSize?: number enableRowSelection?: boolean + selectionToolbar?: (ctx: { + selectedIds: string[] + selectedCount: number + clearSelection: () => void + }) => ReactNode toolbarExtra?: ReactNode hideHeader?: boolean } @@ -113,6 +118,7 @@ export function ResourcePage({ emptyState, pageSize = 10, enableRowSelection = false, + selectionToolbar, toolbarExtra, hideHeader = false, }: ResourcePageProps) { @@ -154,11 +160,17 @@ export function ResourcePage({ return counts }, [tabs, tabFilter, data, filters, getFilterFieldValue]) - const selectedCount = useMemo( - () => Object.keys(rowSelection).length, + const selectedIds = useMemo( + () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], ) + const selectedCount = selectedIds.length + + const clearSelection = useCallback(() => { + setRowSelection({}) + }, []) + const table = useReactTable({ data: filteredData, columns, @@ -245,6 +257,13 @@ export function ResourcePage({ return (
+ {selectionToolbar && selectedCount > 0 + ? selectionToolbar({ + selectedIds, + selectedCount, + clearSelection, + }) + : null} ['variant']> const STATUS_VARIANT: Record = { - active: 'success', - synced: 'success', - ok: 'success', + active: 'success-light', + synced: 'success-light', + ok: 'success-light', + up: 'success-light', pending_push: 'secondary', - warning: 'warning', - conflict: 'destructive', - error: 'destructive', - expired: 'destructive', + warning: 'warning-light', + degraded: 'warning-light', + conflict: 'destructive-light', + error: 'destructive-light', + expired: 'destructive-light', + down: 'destructive-light', unknown: 'outline', } +const DOT_COLOR: Record = { + '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 = { active: 'Активен', synced: 'Синхронизировано', @@ -23,12 +40,29 @@ const STATUS_LABELS: Record = { conflict: 'Конфликт', error: 'Ошибка', ok: 'OK', + up: 'OK', warning: 'Предупреждение', + degraded: 'Slow', + down: 'Down', expired: 'Истёк', 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' - return {label ?? STATUS_LABELS[status] ?? status} + const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground' + return ( + + + {label ?? STATUS_LABELS[status] ?? status} + + ) } diff --git a/apps/web/src/hooks/use-domain-health.ts b/apps/web/src/hooks/use-domain-health.ts new file mode 100644 index 0000000..f30cab3 --- /dev/null +++ b/apps/web/src/hooks/use-domain-health.ts @@ -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 = {} + 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]) +} diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 40e3181..0b7269c 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -115,6 +115,7 @@ export const domainSchema = z.object({ cf_zone_id: z.string(), status: z.string(), 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(), created_at: z.string(), updated_at: z.string(), @@ -123,6 +124,9 @@ export const domainSchema = z.object({ export const domainListItemSchema = domainSchema.extend({ group_name: z.string().nullable(), 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 @@ -369,11 +373,22 @@ export type LoginInput = z.infer export type CreateDnsRecordInput = z.infer export { + bulkUpdateDomainsSchema, + createDomainMonitorSchema, createSubdomainSchema, + domainMonitorResultSchema, + domainMonitorSchema, + notificationLogSchema, subdomainSchema, updateDomainSchema, updateSubdomainSchema, + type BulkUpdateDomainsInput, + type CreateDomainMonitorInput, type CreateSubdomainInput, + type DomainMonitor, + type DomainMonitorResult, + type DomainMonitorType, + type NotificationLog, type SubdomainRecord, type UpdateDomainInput, type UpdateSubdomainInput, diff --git a/apps/web/src/queries/certificates.ts b/apps/web/src/queries/certificates.ts new file mode 100644 index 0000000..85ad6df --- /dev/null +++ b/apps/web/src/queries/certificates.ts @@ -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('/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'), + }) diff --git a/apps/web/src/queries/dns.ts b/apps/web/src/queries/dns.ts new file mode 100644 index 0000000..00e431d --- /dev/null +++ b/apps/web/src/queries/dns.ts @@ -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(`/api/v1/domains/${domainId}/dns`) + return z.array(dnsRecordSchema).parse(data) + }, + staleTime: 1000 * 30, + }) diff --git a/apps/web/src/queries/domain-health.ts b/apps/web/src/queries/domain-health.ts new file mode 100644 index 0000000..344c7b9 --- /dev/null +++ b/apps/web/src/queries/domain-health.ts @@ -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 + +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( + `/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(`/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( + `/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( + `/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(`/api/v1/notifications/log?limit=${limit}`) + return z.array(notificationLogSchema).parse(data) + }, + }) diff --git a/apps/web/src/queries/domains.ts b/apps/web/src/queries/domains.ts new file mode 100644 index 0000000..8cb5e0b --- /dev/null +++ b/apps/web/src/queries/domains.ts @@ -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(`/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(`/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(`/api/v1/domains/${domainId}/subdomains`) + return z.array(subdomainSchema).parse(data) + }, + }) + +export async function createSubdomain(domainId: number, body: CreateSubdomainInput) { + const data = await api.post(`/api/v1/domains/${domainId}/subdomains`, body) + return subdomainSchema.parse(data) +} + +export async function updateSubdomain(id: number, body: UpdateSubdomainInput) { + const data = await api.patch(`/api/v1/subdomains/${id}`, body) + return subdomainSchema.parse(data) +} + +export async function updateDomain(id: number, body: UpdateDomainInput) { + const data = await api.patch(`/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) }) +} diff --git a/apps/web/src/queries/groups.ts b/apps/web/src/queries/groups.ts new file mode 100644 index 0000000..f97554b --- /dev/null +++ b/apps/web/src/queries/groups.ts @@ -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('/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(`/api/v1/groups/${id}`) + return groupWithStatsSchema.parse(data) + }, + }) diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index e8a353e..2fb9774 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -1,238 +1,6 @@ -import { queryOptions } from '@tanstack/react-query' -import { api } from '@/lib/api-client' -import { - certificateSchema, - dnsRecordSchema, - domainListItemSchema, - 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('/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(`/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('/api/v1/service-groups') - return serviceGroupsResponseSchema.parse(data) - }, - }) - -export const servicesQueryOptions = () => - queryOptions({ - queryKey: serviceKeys.all, - queryFn: async () => { - const data = await api.get('/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('/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(`/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(`/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(`/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(`/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('/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(`/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(`/api/v1/domains/${domainId}/subdomains`, body) - return subdomainSchema.parse(data) -} - -export async function updateSubdomain(id: number, body: UpdateSubdomainInput) { - const data = await api.patch(`/api/v1/subdomains/${id}`, body) - return subdomainSchema.parse(data) -} - -export async function updateDomain(id: number, body: UpdateDomainInput) { - const data = await api.patch(`/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('/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( - `/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 * from '@/queries/certificates' +export * from '@/queries/dns' +export * from '@/queries/domain-health' +export * from '@/queries/domains' +export * from '@/queries/groups' +export * from '@/queries/services' diff --git a/apps/web/src/queries/services.ts b/apps/web/src/queries/services.ts new file mode 100644 index 0000000..953b3be --- /dev/null +++ b/apps/web/src/queries/services.ts @@ -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('/api/v1/service-groups') + return serviceGroupsResponseSchema.parse(data) + }, + }) + +export const servicesQueryOptions = () => + queryOptions({ + queryKey: serviceKeys.all, + queryFn: async () => { + const data = await api.get('/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('/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(`/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('/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}`) +} diff --git a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx index 757ba2b..56cafd7 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx @@ -7,7 +7,8 @@ import { toast } from 'sonner' import type { Filter } from '@/components/reui/filters' import { api } from '@/lib/api-client' 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 { DetailPanel, ResourcePage } from '@/components/reui-kit' import { @@ -44,7 +45,10 @@ export const Route = createFileRoute('/_auth/domains/$domainId/dns')({ loader: async ({ context: { queryClient }, params }) => { const id = Number(params.domainId) 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 } }, component: DnsPage, @@ -74,6 +78,8 @@ function DnsPage() { error: recordsErr, refetch: refetchRecords, } = useQuery(dnsListQueryOptions(id)) + const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id)) + const healthByIp = useDomainHealthByIp(bindings) const isLoading = domainLoading || recordsLoading const isError = domainError || recordsError @@ -138,6 +144,7 @@ function DnsPage() { const columns = useDnsColumns({ onDelete: (recordId) => deleteMutation.mutate(recordId), isDeleting: deleteMutation.isPending, + healthByIp, }) const displayRecords = useMemo(() => { diff --git a/apps/web/src/routes/_auth/domains/$domainId/index.tsx b/apps/web/src/routes/_auth/domains/$domainId/index.tsx index a78ba06..27cadfb 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/index.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMemo, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { FolderTreeIcon, GlobeIcon, @@ -7,17 +8,22 @@ import { ServerIcon, ShieldCheckIcon, } from 'lucide-react' +import { toast } from 'sonner' import type { Filter } from '@/components/reui/filters' import type { CertMonitoring } from '@cfdm/shared' import { domainDetailQueryOptions, domainServiceBindingsQueryOptions, + healthStatusKeys, + runHealthCheck, serviceGroupsQueryOptions, servicesQueryOptions, subdomainsListQueryOptions, } from '@/queries' import { useDomainPage } 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 { QueryState } from '@/components/query-state' import { DetailPanel, ResourcePage } from '@/components/reui-kit' @@ -34,6 +40,8 @@ import { type SubdomainEditValues, } from '@/components/subdomain-edit-sheet' 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 { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring' import { formatDate } from '@/lib/format' @@ -48,6 +56,12 @@ import { SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@cfdm/ui/components/tabs' export const Route = createFileRoute('/_auth/domains/$domainId/')({ loader: async ({ context: { queryClient }, params }) => { @@ -81,10 +95,12 @@ function DomainPageSkeleton() { function DomainOverviewPage() { const { domainId } = Route.useParams() const id = Number(domainId) + const queryClient = useQueryClient() const [filters, setFilters] = useState(createDefaultSubdomainFilters) const [sheetOpen, setSheetOpen] = useState(false) const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create') const [editTarget, setEditTarget] = useState(null) + const [activeTab, setActiveTab] = useState('overview') const { domain, @@ -104,6 +120,25 @@ function DomainOverviewPage() { linkServiceMutation, } = 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() { setSheetMode('create') setEditTarget(null) @@ -271,8 +306,16 @@ function DomainOverviewPage() { /> } > - DNS-записи + DNS + runCheckMutation.mutate()} + isLoading={runCheckMutation.isPending} + loadingLabel="Проверка…" + > + Проверить сейчас + ) @@ -289,80 +332,181 @@ function DomainOverviewPage() { -
- - - +
+ + {aggregatedHealth.total > 0 ? ( + + {aggregatedHealth.upCount}/{aggregatedHealth.total} OK + + ) : null}
- - - - - + + + Обзор + + + DNS + + + Доступность + + + Поддомены + + {subdomainRows.length} + + + + Привязки + + {bindings.length} + + + - - ({ ...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={ - - } - emptyState={{ - title: 'Поддомены не созданы', - description: 'Добавьте поддомен для привязки сервисов', - action: ( - - ), - }} - /> - + + + + + + + + + + ({ ...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={ + + } + emptyState={{ + title: 'Поддомены не созданы', + description: 'Добавьте поддомен для привязки сервисов', + action: ( + + ), + }} + /> + + + + + + + + + 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({ onRequestDelete: setDeleteTarget, isDeleting: deleteMutation.isPending, + enableSelection: true, }) const handleCreate = (values: CreateDomainInput) => { @@ -125,14 +139,9 @@ function DomainsPage() { } const primaryAction = ( - <> - - - + ) return ( @@ -155,6 +164,33 @@ function DomainsPage() { error={error} onRetry={refetch} primaryAction={primaryAction} + enableRowSelection + selectionToolbar={({ selectedIds, clearSelection }) => ( + { + 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={{ title: 'Домены не импортированы', description: 'Импортируйте зону из аккаунта Cloudflare', diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 54dae89..8da649f 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { useMemo, useState, useEffect } from 'react' import { + ActivityIcon, AlertTriangleIcon, FolderTreeIcon, } from 'lucide-react' @@ -19,6 +20,7 @@ import { OpsDashboard, type KpiStatCard, } from '@/components/reui-kit' +import { HealthCheckBadge } from '@/components/health-check-badge' import { StatusBadge } from '@/components/status-badge' import { Item, @@ -99,6 +101,22 @@ function DashboardPage() { [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 certOk = countByStatus(summary, ['active', 'ok']) @@ -128,9 +146,11 @@ function DashboardPage() { label: 'Домены', value: domains?.length ?? 0, hint: - ungroupedCount > 0 - ? `${ungroupedCount} без группы` - : 'Все в группах', + attentionCount > 0 + ? `${attentionCount} требуют внимания` + : ungroupedCount > 0 + ? `${ungroupedCount} без группы` + : 'Все в группах', to: '/domains', }, { @@ -174,7 +194,49 @@ function DashboardPage() { } queue={ -
+
+
+
+
+ {attentionDomains.length === 0 ? ( +

+ Нет доменов со статусом Down или Slow +

+ ) : ( + + {attentionDomains.map((domain) => ( + + + + + {domain.zone_name} + + + + + + ))} + + )} +
; + environment: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "environment"; + tableName: "domains"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "last_synced_at"; tableName: "domains"; @@ -2383,6 +2402,578 @@ declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }; dialect: "sqlite"; }>; +declare const domainTags: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "domain_tags"; + schema: undefined; + columns: { + domain_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "domain_id"; + tableName: "domain_tags"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + tag: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tag"; + tableName: "domain_tags"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; +declare const domainMonitors: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "domain_monitors"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + domain_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "domain_id"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + hostname: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "hostname"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "type"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "enabled"; + tableName: "domain_monitors"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "interval_sec"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + timeout_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "timeout_ms"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "path"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "expected_status"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_status"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_latency_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_latency_ms"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_checked_at"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_error: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_error"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; +declare const domainMonitorResults: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "domain_monitor_results"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "domain_monitor_results"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + monitor_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "monitor_id"; + tableName: "domain_monitor_results"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "status"; + tableName: "domain_monitor_results"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + latency_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "latency_ms"; + tableName: "domain_monitor_results"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + error: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "error"; + tableName: "domain_monitor_results"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "checked_at"; + tableName: "domain_monitor_results"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; +declare const notificationLog: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "notification_log"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "notification_log"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ref_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ref_type"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ref_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ref_id"; + tableName: "notification_log"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + title: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "title"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + message: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "message"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; declare const schema: { groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -3099,6 +3690,25 @@ declare const schema: { }, {}, { length: number | undefined; }>; + environment: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "environment"; + tableName: "domains"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "last_synced_at"; tableName: "domains"; @@ -4764,6 +5374,578 @@ declare const schema: { }; dialect: "sqlite"; }>; + domainTags: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "domain_tags"; + schema: undefined; + columns: { + domain_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "domain_id"; + tableName: "domain_tags"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + tag: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tag"; + tableName: "domain_tags"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + domainMonitors: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "domain_monitors"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + domain_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "domain_id"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + hostname: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "hostname"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "type"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "enabled"; + tableName: "domain_monitors"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "interval_sec"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + timeout_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "timeout_ms"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "path"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "expected_status"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_status"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_latency_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_latency_ms"; + tableName: "domain_monitors"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_checked_at"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_error: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_error"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "domain_monitors"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + domainMonitorResults: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "domain_monitor_results"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "domain_monitor_results"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + monitor_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "monitor_id"; + tableName: "domain_monitor_results"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "status"; + tableName: "domain_monitor_results"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + latency_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "latency_ms"; + tableName: "domain_monitor_results"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + error: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "error"; + tableName: "domain_monitor_results"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "checked_at"; + tableName: "domain_monitor_results"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + notificationLog: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "notification_log"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "notification_log"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ref_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ref_type"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ref_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ref_id"; + tableName: "notification_log"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + title: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "title"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + message: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "message"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "notification_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; }; type Sqlite = Database.Database; @@ -4833,7 +6015,12 @@ declare function listDomainsEnriched(db: Db, groupId?: number): DomainListItem[] declare function findDomainByZoneName(db: Db, zoneName: string): Domain | null; declare function getDomain(db: Db, id: number): Domain; declare function createDomain(db: Db, groupId: number | null, zoneName: string, cfZoneId: string): Domain; -declare function updateDomain(db: Db, id: number, groupId: number | null, status: string, certMonitoring?: string): Domain; +declare function updateDomain(db: Db, id: number, patch: { + group_id?: number | null; + status?: string; + cert_monitoring?: string; + environment?: string | null; +}): Domain; declare function deleteDomain(db: Db, id: number): void; declare function setDomainLastSynced(db: Db, id: number): void; declare function listAllDomains(db: Db): Domain[]; @@ -4944,15 +6131,80 @@ declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: nu declare function deleteIpHealthStatusForRef(db: Db, scope: HealthCheckScope, refId: number): void; declare function deleteIpHealthStatusForIp(db: Db, scope: HealthCheckScope, refId: number, ip: string): void; declare function listHealthCheckTargets(db: Db): HealthCheckTarget[]; +declare function listDomainTags(db: Db, domainId: number): string[]; +declare function setDomainTags(db: Db, domainId: number, tags: string[]): void; +declare function addDomainTags(db: Db, domainId: number, tags: string[]): void; +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; +} +declare function listDomainMonitors(db: Db, domainId: number): DomainMonitorRow[]; +declare function listEnabledDomainMonitors(db: Db): DomainMonitorRow[]; +declare function getDomainMonitor(db: Db, id: number): DomainMonitorRow; +declare 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; +declare function deleteDomainMonitor(db: Db, id: number): void; +declare function updateDomainMonitorResult(db: Db, monitorId: number, status: string, latencyMs: number | null, error: string | null): void; +declare function listDomainMonitorResults(db: Db, monitorId: number, limit?: number): { + id: number; + monitor_id: number; + status: string; + latency_ms: number | null; + error: string | null; + checked_at: string; +}[]; +declare function listDomainMonitorResultsForDomain(db: Db, domainId: number, limit?: number): { + id: number; + monitor_id: number; + hostname: string; + type: string; + status: string; + latency_ms: number | null; + error: string | null; + checked_at: string; +}[]; +declare function insertNotificationLog(db: Db, kind: string, refType: string, refId: number | null, title: string, message: string): void; +declare function listNotificationLog(db: Db, limit?: number): { + id: number; + kind: string; + ref_type: string; + ref_id: number | null; + title: string; + message: string; + created_at: string; +}[]; type repos_BindingIpMeta = BindingIpMeta; type repos_BindingLbPatch = BindingLbPatch; type repos_DnsListFilter = DnsListFilter; +type repos_DomainMonitorRow = DomainMonitorRow; type repos_ServiceGroupLbPatch = ServiceGroupLbPatch; type repos_UpdateSubdomainPatch = UpdateSubdomainPatch; +declare const repos_addDomainTags: typeof addDomainTags; declare const repos_bindingsToRemove: typeof bindingsToRemove; declare const repos_countCertificatesByStatus: typeof countCertificatesByStatus; declare const repos_createDomain: typeof createDomain; +declare const repos_createDomainMonitor: typeof createDomainMonitor; declare const repos_createGroup: typeof createGroup; declare const repos_createService: typeof createService; declare const repos_createServiceGroup: typeof createServiceGroup; @@ -4963,6 +6215,7 @@ declare const repos_deleteBindingsExcept: typeof deleteBindingsExcept; declare const repos_deleteCertificatesNotIn: typeof deleteCertificatesNotIn; declare const repos_deleteDnsRecord: typeof deleteDnsRecord; declare const repos_deleteDomain: typeof deleteDomain; +declare const repos_deleteDomainMonitor: typeof deleteDomainMonitor; declare const repos_deleteGroup: typeof deleteGroup; declare const repos_deleteIpHealthStatusForIp: typeof deleteIpHealthStatusForIp; declare const repos_deleteIpHealthStatusForRef: typeof deleteIpHealthStatusForRef; @@ -4979,6 +6232,7 @@ declare const repos_getBindingView: typeof getBindingView; declare const repos_getCertificate: typeof getCertificate; declare const repos_getDnsRecord: typeof getDnsRecord; declare const repos_getDomain: typeof getDomain; +declare const repos_getDomainMonitor: typeof getDomainMonitor; declare const repos_getGroup: typeof getGroup; declare const repos_getGroupWithStats: typeof getGroupWithStats; declare const repos_getIpHealthStatusRow: typeof getIpHealthStatusRow; @@ -4988,6 +6242,7 @@ declare const repos_getSubdomain: typeof getSubdomain; declare const repos_getSyncJob: typeof getSyncJob; declare const repos_insertBinding: typeof insertBinding; declare const repos_insertDnsRecord: typeof insertDnsRecord; +declare const repos_insertNotificationLog: typeof insertNotificationLog; declare const repos_linkBindingRecord: typeof linkBindingRecord; declare const repos_linkGroupDnsRecord: typeof linkGroupDnsRecord; declare const repos_listAllBindings: typeof listAllBindings; @@ -5000,12 +6255,18 @@ declare const repos_listBindingsByService: typeof listBindingsByService; declare const repos_listCertificates: typeof listCertificates; declare const repos_listDnsByDomain: typeof listDnsByDomain; declare const repos_listDnsRecords: typeof listDnsRecords; +declare const repos_listDomainMonitorResults: typeof listDomainMonitorResults; +declare const repos_listDomainMonitorResultsForDomain: typeof listDomainMonitorResultsForDomain; +declare const repos_listDomainMonitors: typeof listDomainMonitors; +declare const repos_listDomainTags: typeof listDomainTags; declare const repos_listDomains: typeof listDomains; declare const repos_listDomainsEnriched: typeof listDomainsEnriched; +declare const repos_listEnabledDomainMonitors: typeof listEnabledDomainMonitors; declare const repos_listGroupDnsRecords: typeof listGroupDnsRecords; declare const repos_listGroups: typeof listGroups; declare const repos_listHealthCheckTargets: typeof listHealthCheckTargets; declare const repos_listIpHealthStatus: typeof listIpHealthStatus; +declare const repos_listNotificationLog: typeof listNotificationLog; declare const repos_listRecordsForBinding: typeof listRecordsForBinding; declare const repos_listServiceGroups: typeof listServiceGroups; declare const repos_listServiceIps: typeof listServiceIps; @@ -5022,6 +6283,7 @@ declare const repos_setBindingCnameTarget: typeof setBindingCnameTarget; declare const repos_setBindingDnsRecordId: typeof setBindingDnsRecordId; declare const repos_setDnsSyncStatus: typeof setDnsSyncStatus; declare const repos_setDomainLastSynced: typeof setDomainLastSynced; +declare const repos_setDomainTags: typeof setDomainTags; declare const repos_setServiceEnabled: typeof setServiceEnabled; declare const repos_setServiceGroup: typeof setServiceGroup; declare const repos_setServiceGroupEnabled: typeof setServiceGroupEnabled; @@ -5032,6 +6294,7 @@ declare const repos_updateBindingFields: typeof updateBindingFields; declare const repos_updateBindingLbConfig: typeof updateBindingLbConfig; declare const repos_updateDnsFields: typeof updateDnsFields; declare const repos_updateDomain: typeof updateDomain; +declare const repos_updateDomainMonitorResult: typeof updateDomainMonitorResult; declare const repos_updateGroup: typeof updateGroup; declare const repos_updateService: typeof updateService; declare const repos_updateServiceGroup: typeof updateServiceGroup; @@ -5040,7 +6303,7 @@ declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck; declare const repos_upsertIpHealthStatus: typeof upsertIpHealthStatus; declare const repos_upsertSubdomain: typeof upsertSubdomain; declare namespace repos { - export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteGroup as deleteGroup, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listIpHealthStatus as listIpHealthStatus, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; + export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listIpHealthStatus as listIpHealthStatus, repos_listNotificationLog as listNotificationLog, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setDomainTags as setDomainTags, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateDomainMonitorResult as updateDomainMonitorResult, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; } -export { type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, certificates, createDb, createMemoryDb, dnsRecords, domains, getAppSettings, getAppSettingsSecrets, getAppSwitcher, groups, healthCheck, ipHealthStatus, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; +export { type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, getAppSettings, getAppSettingsSecrets, getAppSwitcher, groups, healthCheck, ipHealthStatus, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index 3b1c972..c5df269 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -62,6 +62,7 @@ var domains = sqliteTable("domains", { cf_zone_id: text("cf_zone_id").notNull(), status: text("status").notNull().default("active"), cert_monitoring: text("cert_monitoring").notNull().default("auto"), + environment: text("environment"), last_synced_at: text("last_synced_at"), created_at: text("created_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')`), 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 = { groups, services, @@ -211,7 +254,11 @@ var schema = { certificates, syncJobs, ipHealthStatus, - appSettings + appSettings, + domainTags, + domainMonitors, + domainMonitorResults, + notificationLog }; // src/client.ts @@ -367,9 +414,11 @@ function getAppSwitcher(db) { // src/repos.ts var repos_exports = {}; __export(repos_exports, { + addDomainTags: () => addDomainTags, bindingsToRemove: () => bindingsToRemove, countCertificatesByStatus: () => countCertificatesByStatus, createDomain: () => createDomain, + createDomainMonitor: () => createDomainMonitor, createGroup: () => createGroup, createService: () => createService, createServiceGroup: () => createServiceGroup, @@ -380,6 +429,7 @@ __export(repos_exports, { deleteCertificatesNotIn: () => deleteCertificatesNotIn, deleteDnsRecord: () => deleteDnsRecord, deleteDomain: () => deleteDomain, + deleteDomainMonitor: () => deleteDomainMonitor, deleteGroup: () => deleteGroup, deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp, deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef, @@ -396,6 +446,7 @@ __export(repos_exports, { getCertificate: () => getCertificate, getDnsRecord: () => getDnsRecord, getDomain: () => getDomain, + getDomainMonitor: () => getDomainMonitor, getGroup: () => getGroup, getGroupWithStats: () => getGroupWithStats, getIpHealthStatusRow: () => getIpHealthStatusRow, @@ -405,6 +456,7 @@ __export(repos_exports, { getSyncJob: () => getSyncJob, insertBinding: () => insertBinding, insertDnsRecord: () => insertDnsRecord, + insertNotificationLog: () => insertNotificationLog, linkBindingRecord: () => linkBindingRecord, linkGroupDnsRecord: () => linkGroupDnsRecord, listAllBindings: () => listAllBindings, @@ -417,12 +469,18 @@ __export(repos_exports, { listCertificates: () => listCertificates, listDnsByDomain: () => listDnsByDomain, listDnsRecords: () => listDnsRecords, + listDomainMonitorResults: () => listDomainMonitorResults, + listDomainMonitorResultsForDomain: () => listDomainMonitorResultsForDomain, + listDomainMonitors: () => listDomainMonitors, + listDomainTags: () => listDomainTags, listDomains: () => listDomains, listDomainsEnriched: () => listDomainsEnriched, + listEnabledDomainMonitors: () => listEnabledDomainMonitors, listGroupDnsRecords: () => listGroupDnsRecords, listGroups: () => listGroups, listHealthCheckTargets: () => listHealthCheckTargets, listIpHealthStatus: () => listIpHealthStatus, + listNotificationLog: () => listNotificationLog, listRecordsForBinding: () => listRecordsForBinding, listServiceGroups: () => listServiceGroups, listServiceIps: () => listServiceIps, @@ -439,6 +497,7 @@ __export(repos_exports, { setBindingDnsRecordId: () => setBindingDnsRecordId, setDnsSyncStatus: () => setDnsSyncStatus, setDomainLastSynced: () => setDomainLastSynced, + setDomainTags: () => setDomainTags, setServiceEnabled: () => setServiceEnabled, setServiceGroup: () => setServiceGroup, setServiceGroupEnabled: () => setServiceGroupEnabled, @@ -449,6 +508,7 @@ __export(repos_exports, { updateBindingLbConfig: () => updateBindingLbConfig, updateDnsFields: () => updateDnsFields, updateDomain: () => updateDomain, + updateDomainMonitorResult: () => updateDomainMonitorResult, updateGroup: () => updateGroup, updateService: () => updateService, updateServiceGroup: () => updateServiceGroup, @@ -497,14 +557,61 @@ function listDomains(db, groupId) { } function listDomainsEnriched(db, groupId) { 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 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 LEFT JOIN groups g ON g.id = d.group_id ${base} 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) { const rows = db.all(sql2` @@ -525,14 +632,18 @@ function createDomain(db, groupId, zoneName, cfZoneId) { }).returning({ id: domains.id }).get().id; return getDomain(db, id); } -function updateDomain(db, id, groupId, status, certMonitoring) { +function updateDomain(db, id, patch) { + const existing = getDomain(db, id); const updates = { - group_id: groupId, - status, + group_id: patch.group_id !== void 0 ? patch.group_id : existing.group_id, + status: patch.status !== void 0 ? patch.status : existing.status, updated_at: sql2`datetime('now')` }; - if (certMonitoring !== void 0) { - updates.cert_monitoring = certMonitoring; + if (patch.cert_monitoring !== void 0) { + 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(); if (result.changes === 0) throw new NotFoundError(`domain ${id}`); @@ -1320,6 +1431,115 @@ function listHealthCheckTargets(db) { ...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 { ConflictError, NotFoundError, @@ -1328,6 +1548,9 @@ export { createDb, createMemoryDb, dnsRecords, + domainMonitorResults, + domainMonitors, + domainTags, domains, getAppSettings, getAppSettingsSecrets, @@ -1335,6 +1558,7 @@ export { groups, healthCheck, ipHealthStatus, + notificationLog, repos_exports as repos, resolveDatabasePath, runMigrations, diff --git a/packages/db/migrations/014_domain_env_tags_monitors.sql b/packages/db/migrations/014_domain_env_tags_monitors.sql new file mode 100644 index 0000000..0a4f165 --- /dev/null +++ b/packages/db/migrations/014_domain_env_tags_monitors.sql @@ -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); diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index b3a7dbf..f77d8df 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -24,9 +24,13 @@ import { NotFoundError } from "./errors.js"; import { certificates, dnsRecords, + domainMonitorResults, + domainMonitors, + domainTags, domains, groups, ipHealthStatus, + notificationLog, serviceBindingIps, serviceBindingRecords, serviceBindings, @@ -119,14 +123,65 @@ export function listDomainsEnriched(db: Db, groupId?: number): DomainListItem[] const base = groupId != null ? sql`WHERE d.group_id = ${groupId}` : sql``; - return db.all(sql` + const rows = db.all< + Omit & { tags_json: string | null } + >(sql` 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 LEFT JOIN groups g ON g.id = d.group_id ${base} 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 { @@ -163,22 +218,24 @@ export function createDomain( export function updateDomain( db: Db, id: number, - groupId: number | null, - status: string, - certMonitoring?: string, -): Domain { - const updates: { - group_id: number | null; - status: string; + patch: { + group_id?: number | null; + status?: string; cert_monitoring?: string; - updated_at: ReturnType; - } = { - group_id: groupId, - status, + environment?: string | null; + }, +): Domain { + const existing = getDomain(db, id); + const updates: Record = { + 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')`, }; - if (certMonitoring !== undefined) { - updates.cert_monitoring = certMonitoring; + if (patch.cert_monitoring !== undefined) { + updates.cert_monitoring = patch.cert_monitoring; + } + if (patch.environment !== undefined) { + updates.environment = patch.environment; } const result = db .update(domains) @@ -1595,3 +1652,246 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { ...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} + `); +} + diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 15c1398..15e5d18 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -77,6 +77,7 @@ export const domains = sqliteTable("domains", { cf_zone_id: text("cf_zone_id").notNull(), status: text("status").notNull().default("active"), cert_monitoring: text("cert_monitoring").notNull().default("auto"), + environment: text("environment"), last_synced_at: text("last_synced_at"), created_at: text("created_at") .notNull() @@ -287,6 +288,66 @@ export const appSettings = sqliteTable("app_settings", { .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 = { groups, services, @@ -303,4 +364,8 @@ export const schema = { syncJobs, ipHealthStatus, appSettings, + domainTags, + domainMonitors, + domainMonitorResults, + notificationLog, }; diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 7c3e7ee..9966522 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -152,7 +152,7 @@ interface JwtClaims { exp: number; } type LbMode = "round_robin" | "failover" | "weighted"; -type HealthCheckType = "tcp" | "http"; +type HealthCheckType = "tcp" | "http" | "ping" | "dns"; type IpHealthState = "up" | "down" | "degraded" | "unknown"; type HealthCheckScope = "binding" | "group"; interface IpHealthStatus { @@ -221,7 +221,21 @@ declare const lbModeSchema: z.ZodEnum<{ declare const healthCheckTypeSchema: z.ZodEnum<{ tcp: "tcp"; http: "http"; + ping: "ping"; + dns: "dns"; }>; +declare const domainEnvironmentSchema: z.ZodEnum<{ + prod: "prod"; + staging: "staging"; + dev: "dev"; +}>; +type DomainEnvironment = z.infer; +declare const domainMonitorTypeSchema: z.ZodEnum<{ + http: "http"; + ping: "ping"; + dns: "dns"; +}>; +type DomainMonitorType = z.infer; declare const ipHealthStateSchema: z.ZodEnum<{ unknown: "unknown"; up: "up"; @@ -294,6 +308,8 @@ declare const serviceGroupSchema: z.ZodObject<{ health_check_type: z.ZodCatch>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -340,6 +356,8 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -360,7 +378,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -447,7 +467,7 @@ declare const serviceViewSchema: z.ZodObject<{ fqdn: string; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -464,7 +484,7 @@ declare const serviceViewSchema: z.ZodObject<{ record_type: "A" | "CNAME"; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -500,6 +520,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{ health_check_type: z.ZodCatch>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -545,6 +567,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{ health_check_type: z.ZodCatch>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -565,7 +589,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{ fqdn: string; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -582,7 +606,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{ record_type: "A" | "CNAME"; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -620,6 +644,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_type: z.ZodCatch>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -665,6 +691,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_type: z.ZodCatch>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -685,7 +713,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ fqdn: string; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -702,7 +730,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ record_type: "A" | "CNAME"; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -754,6 +782,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ health_check_type: z.ZodCatch>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -774,7 +804,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ fqdn: string; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -791,7 +821,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ record_type: "A" | "CNAME"; lb_mode: "round_robin" | "failover" | "weighted"; health_check_enabled: boolean; - health_check_type: "tcp" | "http"; + health_check_type: "tcp" | "http" | "ping" | "dns"; health_check_port: number | null; health_check_path: string | null; health_check_expected_status: number | null; @@ -817,6 +847,11 @@ declare const domainSchema: z.ZodObject<{ required: "required"; skipped: "skipped"; }>>; + environment: z.ZodDefault>>>; last_synced_at: z.ZodNullable; created_at: z.ZodString; updated_at: z.ZodString; @@ -832,11 +867,24 @@ declare const domainListItemSchema: z.ZodObject<{ required: "required"; skipped: "skipped"; }>>; + environment: z.ZodDefault>>>; last_synced_at: z.ZodNullable; created_at: z.ZodString; updated_at: z.ZodString; group_name: z.ZodNullable; service_count: z.ZodNumber; + health_status: z.ZodDefault>; + health_latency_ms: z.ZodDefault>; + tags: z.ZodDefault>; }, z.core.$strip>; declare const serviceBindingSchema: z.ZodPipe>; health_check_port: z.ZodNullable; health_check_path: z.ZodNullable; @@ -888,7 +938,7 @@ declare const serviceBindingSchema: z.ZodPipe>; health_check_port: z.ZodOptional>; health_check_path: z.ZodOptional>; @@ -997,6 +1049,8 @@ declare const createServiceWithConfigSchema: z.ZodObject<{ health_check_type: z.ZodOptional>; health_check_port: z.ZodOptional>; health_check_path: z.ZodOptional>; @@ -1081,7 +1135,89 @@ declare const updateDomainSchema: z.ZodObject<{ required: "required"; skipped: "skipped"; }>>; + environment: z.ZodOptional>>; + tags: z.ZodOptional>; }, z.core.$strip>; +declare const bulkUpdateDomainsSchema: z.ZodObject<{ + ids: z.ZodArray; + group_id: z.ZodOptional>; + environment: z.ZodOptional>>; + tags_add: z.ZodOptional>; +}, z.core.$strip>; +type BulkUpdateDomainsInput = z.infer; +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; + interval_sec: z.ZodNumber; + timeout_ms: z.ZodNumber; + path: z.ZodNullable; + expected_status: z.ZodNullable; + last_status: z.ZodDefault>; + last_latency_ms: z.ZodNullable; + last_checked_at: z.ZodNullable; + last_error: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; +}, z.core.$strip>; +type DomainMonitor = z.infer; +declare const createDomainMonitorSchema: z.ZodObject<{ + hostname: z.ZodString; + type: z.ZodEnum<{ + http: "http"; + ping: "ping"; + dns: "dns"; + }>; + enabled: z.ZodDefault>; + interval_sec: z.ZodDefault>; + timeout_ms: z.ZodDefault>; + path: z.ZodOptional>; + expected_status: z.ZodOptional>; +}, z.core.$strip>; +type CreateDomainMonitorInput = z.infer; +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; + error: z.ZodNullable; + checked_at: z.ZodString; +}, z.core.$strip>; +type DomainMonitorResult = z.infer; +declare const notificationLogSchema: z.ZodObject<{ + id: z.ZodNumber; + kind: z.ZodString; + ref_type: z.ZodString; + ref_id: z.ZodNullable; + title: z.ZodString; + message: z.ZodString; + created_at: z.ZodString; +}, z.core.$strip>; +type NotificationLog = z.infer; type CreateSubdomainInput = z.infer; type UpdateSubdomainInput = z.infer; type UpdateDomainInput = z.infer; @@ -1100,6 +1236,8 @@ declare const updateServiceConfigSchema: z.ZodObject<{ health_check_type: z.ZodOptional>; health_check_port: z.ZodOptional>; health_check_path: z.ZodOptional>; @@ -1124,6 +1262,8 @@ declare const createServiceGroupSchema: z.ZodObject<{ health_check_type: z.ZodOptional>; health_check_port: z.ZodOptional>; health_check_path: z.ZodOptional>; @@ -1151,6 +1291,8 @@ declare const updateServiceGroupSchema: z.ZodObject<{ health_check_type: z.ZodOptional>; health_check_port: z.ZodOptional>; health_check_path: z.ZodOptional>; @@ -1300,4 +1442,4 @@ declare const vpsTrackerEventSchema: z.ZodObject<{ }, z.core.$strip>; type VpsTrackerEvent = z.infer; -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 }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 915b876..73950ca 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -161,7 +161,9 @@ function bindingToFqdn(binding) { import { z } from "zod"; var certMonitoringSchema = z.enum(["auto", "required", "skipped"]); 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 healthCheckScopeSchema = z.enum(["binding", "group"]); var ipHealthStatusSchema = z.object({ @@ -271,13 +273,17 @@ var domainSchema = z.object({ cf_zone_id: z.string(), status: z.string(), cert_monitoring: certMonitoringSchema.default("auto"), + environment: domainEnvironmentSchema.nullable().optional().default(null), last_synced_at: z.string().nullable(), created_at: z.string(), updated_at: z.string() }); var domainListItemSchema = domainSchema.extend({ 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({ id: z.number(), @@ -440,7 +446,58 @@ var updateSubdomainSchema = z.object({ var updateDomainSchema = z.object({ group_id: z.number().nullable().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({ 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, appSwitcherIconSchema, bindingToFqdn, + bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, + createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, @@ -571,7 +630,11 @@ export { dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, + domainEnvironmentSchema, domainListItemSchema, + domainMonitorResultSchema, + domainMonitorSchema, + domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, @@ -586,6 +649,7 @@ export { lbModeSchema, loginSchema, normalizeDnsRecordName, + notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 4098e7c..14e522c 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -7,9 +7,15 @@ export type CertMonitoring = z.infer export const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted']) export type LbMode = z.infer -export const healthCheckTypeSchema = z.enum(['tcp', 'http']) +export const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns']) export type HealthCheckType = z.infer +export const domainEnvironmentSchema = z.enum(['prod', 'staging', 'dev']) +export type DomainEnvironment = z.infer + +export const domainMonitorTypeSchema = z.enum(['http', 'ping', 'dns']) +export type DomainMonitorType = z.infer + export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown']) export type IpHealthState = z.infer @@ -144,6 +150,7 @@ export const domainSchema = z.object({ cf_zone_id: z.string(), status: z.string(), cert_monitoring: certMonitoringSchema.default('auto'), + environment: domainEnvironmentSchema.nullable().optional().default(null), last_synced_at: z.string().nullable(), created_at: z.string(), updated_at: z.string(), @@ -152,6 +159,9 @@ export const domainSchema = z.object({ export const domainListItemSchema = domainSchema.extend({ group_name: z.string().nullable(), 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 @@ -361,8 +371,74 @@ export const updateDomainSchema = z.object({ group_id: z.number().nullable().optional(), status: z.string().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 + +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 + +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 + +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 + +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 + export type CreateSubdomainInput = z.infer export type UpdateSubdomainInput = z.infer export type UpdateDomainInput = z.infer diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6bf3424..eb88944 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -55,6 +55,7 @@ export interface Domain { cf_zone_id: string; status: string; cert_monitoring: string; + environment: DomainEnvironment | null; last_synced_at: string | null; created_at: string; updated_at: string; @@ -193,6 +194,9 @@ export interface GroupWithStats extends Group { export interface DomainListItem extends Domain { group_name: string | null; service_count: number; + health_status: IpHealthState; + health_latency_ms: number | null; + tags: string[]; } export interface SyncJob { @@ -246,7 +250,11 @@ export interface JwtClaims { 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";