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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 02:25:12 +07:00
co-authored by Cursor
parent dd167a4fec
commit 64585ccd47
38 changed files with 4199 additions and 677 deletions
+38 -7
View File
@@ -7,6 +7,7 @@ import {
} from "@fastify/type-provider-zod";
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");
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyInstance } from "fastify";
import { createDomainMonitorSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
export async function domainMonitorRoutes(app: FastifyInstance) {
app.get("/domains/:id/monitors", async (request) => {
const { id } = request.params as { id: string };
return repos.listDomainMonitors(request.server.db, Number(id));
});
app.post("/domains/:id/monitors", async (request) => {
const { id } = request.params as { id: string };
const body = createDomainMonitorSchema.parse(request.body);
return repos.createDomainMonitor(request.server.db, Number(id), body);
});
app.delete("/domains/:domainId/monitors/:monitorId", async (request) => {
const { monitorId } = request.params as {
domainId: string;
monitorId: string;
};
repos.deleteDomainMonitor(request.server.db, Number(monitorId));
return { deleted: true };
});
app.get("/domains/:id/monitor-results", async (request) => {
const { id } = request.params as { id: string };
const query = request.query as { limit?: string };
const limit = query.limit ? Number(query.limit) : 50;
return repos.listDomainMonitorResultsForDomain(
request.server.db,
Number(id),
limit,
);
});
app.post("/domains/:id/monitors/run", async (request) => {
const config = request.server.config;
const checked = await healthCheckService.runDomainMonitors(request.server.db, {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
});
return { checked };
});
}
export async function notificationRoutes(app: FastifyInstance) {
app.get("/notifications/log", async (request) => {
const query = request.query as { limit?: string };
const limit = query.limit ? Number(query.limit) : 50;
return repos.listNotificationLog(request.server.db, limit);
});
}
+16 -9
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { 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) => {
+30 -8
View File
@@ -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 };
});
}
+44 -4
View File
@@ -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 {
+121 -4
View File
@@ -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<ProbeResult> {
const ports = [443, 80];
let last: ProbeResult = {
ok: false,
latencyMs: 0,
error: "unreachable",
};
for (const port of ports) {
last = await tcpProbe(hostname, port, timeoutMs);
if (last.ok) return last;
}
return last;
}
async function dnsProbe(hostname: string): Promise<ProbeResult> {
const started = Date.now();
try {
const [v4, v6] = await Promise.allSettled([
resolve4(hostname),
resolve6(hostname),
]);
const hasV4 = v4.status === "fulfilled" && v4.value.length > 0;
const hasV6 = v6.status === "fulfilled" && v6.value.length > 0;
if (!hasV4 && !hasV6) {
return {
ok: false,
latencyMs: Date.now() - started,
error: "no A/AAAA records",
};
}
return { ok: true, latencyMs: Date.now() - started, error: null };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - started,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function probeTarget(
target: HealthCheckTarget,
): Promise<ProbeResult> {
@@ -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<number> {
const monitors = repos.listEnabledDomainMonitors(db);
let checked = 0;
for (const monitor of monitors) {
const target: HealthCheckTarget = {
scope: "binding",
ref_id: monitor.id,
ip: monitor.hostname,
hostname: monitor.hostname,
type: monitor.type as HealthCheckTarget["type"],
port: monitor.type === "http" ? (monitor.path?.includes("443") ? 443 : 80) : null,
path: monitor.path,
expected_status: monitor.expected_status,
timeout_ms: monitor.timeout_ms,
};
let result: ProbeResult;
if (monitor.type === "http") {
result = await httpProbe(monitor.hostname, {
...target,
port: 443,
path: monitor.path ?? "/",
}, monitor.timeout_ms);
if (!result.ok) {
result = await httpProbe(monitor.hostname, {
...target,
port: 80,
path: monitor.path ?? "/",
}, monitor.timeout_ms);
}
} else if (monitor.type === "ping") {
result = await pingProbe(monitor.hostname, monitor.timeout_ms);
} else {
result = await dnsProbe(monitor.hostname);
}
const prevStatus = monitor.last_status as IpHealthState;
const { state } = deriveState(
result.ok,
result.latencyMs,
{
consecutive_failures: result.ok ? 0 : 1,
status: prevStatus,
},
thresholds,
);
repos.updateDomainMonitorResult(
db,
monitor.id,
state,
result.latencyMs,
result.error,
);
if (prevStatus !== state && prevStatus !== "unknown") {
const label =
state === "up" ? "OK" : state === "degraded" ? "Slow" : state === "down" ? "Down" : "—";
repos.insertNotificationLog(
db,
"domain_monitor",
"domain_monitor",
monitor.id,
`${monitor.hostname}: ${label}`,
result.error
? `${monitor.type.toUpperCase()}${label}. ${result.error}`
: `${monitor.type.toUpperCase()}${label}${result.latencyMs != null ? ` (${result.latencyMs} мс)` : ""}`,
);
}
checked += 1;
}
return checked;
}
export function listStatus(
db: Db,
scope: "binding" | "group",
+15 -21
View File
@@ -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,
@@ -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 (
<Badge variant="outline" size="xs" className="font-mono">
{env}
</Badge>
)
}
export function useDomainColumns({
onRequestDelete,
isDeleting,
enableSelection = false,
}: {
onRequestDelete: (domain: DomainListItem) => void
isDeleting?: boolean
enableSelection?: boolean
}) {
const columns = useMemo<ColumnDef<DomainListItem>[]>(
() => [
{
id: 'zone_name',
accessorKey: 'zone_name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Зона" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto p-0 font-medium"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(row.original.id) }}
/>
}
>
{row.original.zone_name}
</Button>
),
},
{
id: 'group',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Группа" />
),
cell: ({ row }) => {
const domain = row.original
if (domain.group_id && domain.group_name) {
return (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
{domain.group_name}
</Button>
)
}
return <Badge variant="outline">Без группы</Badge>
},
},
{
id: 'service_count',
accessorKey: 'service_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={
<Link to="/services" search={{ domainId: row.original.id }} />
}
>
{row.original.service_count}
</Button>
),
},
{
id: 'status',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'last_synced_at',
accessorKey: 'last_synced_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Синхронизация" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.last_synced_at ?? '—'}
</span>
),
},
{
id: 'actions',
header: '',
enableHiding: false,
cell: ({ row }) => (
<div className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon" className="size-8" />}
>
<MoreHorizontalIcon />
<span className="sr-only">Действия</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
() => {
const cols: ColumnDef<DomainListItem>[] = []
if (enableSelection) {
cols.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
size: 36,
enableSorting: false,
enableHiding: false,
})
}
cols.push(
{
id: 'zone_name',
accessorKey: 'zone_name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Зона" />
),
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
@@ -188,33 +163,155 @@ export function useDomainColumns({
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem
{row.original.zone_name}
</Button>
<EnvChip env={row.original.environment} />
</div>
{(row.original.tags ?? []).length > 0 ? (
<div className="flex flex-wrap gap-1">
{row.original.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" size="xs">
{tag}
</Badge>
))}
</div>
) : null}
</div>
),
},
{
id: 'health',
accessorKey: 'health_status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Доступность" />
),
cell: ({ row }) => (
<HealthCheckBadge
status={row.original.health_status ?? 'unknown'}
latencyMs={row.original.health_latency_ms}
showLatency
/>
),
},
{
id: 'group',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Группа" />
),
cell: ({ row }) => {
const domain = row.original
if (domain.group_id && domain.group_name) {
return (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId: String(row.original.id) }}
search={{ host: undefined }}
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
DNS
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDeleting}
onClick={() => onRequestDelete(row.original)}
{domain.group_name}
</Button>
)
}
return <Badge variant="outline">Без группы</Badge>
},
},
{
id: 'service_count',
accessorKey: 'service_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={
<Link to="/services" search={{ domainId: row.original.id }} />
}
>
{row.original.service_count}
</Button>
),
},
{
id: 'status',
header: 'Зона CF',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'last_synced_at',
accessorKey: 'last_synced_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Синхронизация" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatRelative(
sqliteUtcToIso(row.original.last_synced_at) ??
row.original.last_synced_at,
)}
</span>
),
},
{
id: 'actions',
header: '',
enableHiding: false,
cell: ({ row }) => (
<div className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon" className="size-8" />
}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[isDeleting, onRequestDelete],
<MoreHorizontalIcon />
<span className="sr-only">Действия</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(row.original.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId: String(row.original.id) }}
search={{ host: undefined }}
/>
}
>
DNS
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDeleting}
onClick={() => onRequestDelete(row.original)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
)
return cols
},
[enableSelection, isDeleting, onRequestDelete],
)
return { columns }
+48 -111
View File
@@ -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<IpHealthStatus['status'], string> = {
up: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
unknown: 'bg-muted-foreground/40',
}
const healthLabel: Record<IpHealthStatus['status'], string> = {
up: 'OK',
degraded: 'Деград.',
down: 'Down',
unknown: '—',
}
function IpHealthDot({ health }: { health: IpHealthStatus }) {
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
'inline-flex size-2 shrink-0 cursor-default rounded-full',
healthDotClass[health.status],
)}
aria-label={`Health: ${healthLabel[health.status]}`}
/>
}
/>
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function HostnameIpsHealth({
bindings,
ips,
@@ -95,9 +48,18 @@ function HostnameIpsHealth({
{ips.map((ip) => {
const row = byIp.get(ip)
return (
<span key={ip} className="inline-flex items-center gap-1 tabular-nums">
{row ? <IpHealthDot health={row} /> : null}
{ip}
<span key={ip} className="inline-flex items-center gap-1.5 tabular-nums">
{row ? (
<HealthCheckBadge
status={row.status}
latencyMs={row.latency_ms}
lastCheckedAt={row.last_checked_at}
lastError={row.last_error}
size="xs"
showLatency
/>
) : null}
<span className="font-mono text-sm">{ip}</span>
</span>
)
})}
@@ -124,83 +86,58 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<EmptyState
icon={Link2Icon}
title="Нет привязок"
description="Создайте привязку на странице сервисов или в таблице поддоменов"
className="border border-dashed p-4"
description="Привяжите сервис к поддомену"
/>
) : (
<AppItemGroup className="gap-3">
{entries.map(([hostname, hostnameBindings], index) => {
const services = uniqueServices(hostnameBindings)
const uniqueIps = [
<AppItemGroup className="gap-0">
{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 (
<div key={hostname}>
<AppItem variant="outline">
<AppItemContent className="gap-2">
<Tooltip>
<TooltipTrigger
render={
<AppItemTitle className="cursor-default truncate font-mono">
{hostname}
</AppItemTitle>
}
/>
<TooltipContent>{hostname}</TooltipContent>
</Tooltip>
<div className="flex flex-wrap items-center gap-2">
{services.map((name) => (
<AppBadge key={name}>{name}</AppBadge>
))}
{uniqueIps.length > 0 && (
<span className="text-xs text-muted-foreground">
<HostnameIpsHealth
bindings={hostnameBindings}
ips={uniqueIps}
/>
</span>
)}
{[
...new Set(
hostnameBindings
.map((b) => b.sync_status)
.filter((s): s is string => Boolean(s)),
),
].map((status) => (
<StatusBadge key={status} status={status} />
))}
<AppItem size="sm" variant="muted" className="border-0 px-0">
<AppItemContent className="gap-1">
<AppItemTitle className="font-mono">{hostname}</AppItemTitle>
<div className="text-muted-foreground text-sm">
{uniqueServices(groupBindings).join(', ')}
</div>
<HostnameIpsHealth
bindings={groupBindings}
ips={ips}
/>
</AppItemContent>
<AppItemActions>
<AppButton
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/services" search={{ domainId: undefined }} />}
>
Сервисы
</AppButton>
<AppBadge variant="outline" className="tabular-nums">
{ips.length} IP
</AppBadge>
</AppItemActions>
</AppItem>
{index < entries.length - 1 && <AppItemSeparator />}
{index < entries.length - 1 ? <AppItemSeparator /> : null}
</div>
)
})}
</AppItemGroup>
)}
</AppCardContent>
{entries.length > 0 && (
<AppCardFooter>
<AppButton variant="link" className="h-auto p-0" nativeButton={false} render={<Link to="/services" search={{ domainId: undefined }} />}>
Управление привязками
</AppButton>
</AppCardFooter>
)}
<AppCardFooter>
<AppButton
variant="link"
className="h-auto p-0"
nativeButton={false}
render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />}
>
К сервисам
</AppButton>
</AppCardFooter>
</AppCard>
)
}
@@ -0,0 +1,334 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ActivityIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
import type { DomainMonitorType } from '@/lib/schemas'
import {
createDomainMonitor,
deleteDomainMonitor,
domainMonitorKeys,
domainMonitorResultsQueryOptions,
domainMonitorsQueryOptions,
runDomainMonitors,
} from '@/queries'
import { HealthTimeline } from '@/components/health/health-timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { EmptyState } from '@/components/empty-state'
import { LoadingButton } from '@/components/loading-button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
AppField,
AppFieldGroup,
AppFieldLabel,
} from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
AppItem,
AppItemActions,
AppItemContent,
AppItemGroup,
AppItemSeparator,
AppItemTitle,
} from '@/components/app-item'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { formatDate } from '@/lib/format'
const MONITOR_TYPE_ITEMS: { label: string; value: DomainMonitorType }[] = [
{ label: 'HTTP', value: 'http' },
{ label: 'Ping', value: 'ping' },
{ label: 'DNS', value: 'dns' },
]
function monitorTypeLabel(type: string): string {
const found = MONITOR_TYPE_ITEMS.find((item) => item.value === type)
return found?.label ?? type
}
interface DomainAvailabilityPanelProps {
domainId: number
}
export function DomainAvailabilityPanel({
domainId,
}: DomainAvailabilityPanelProps) {
const queryClient = useQueryClient()
const [hostname, setHostname] = useState('')
const [monitorType, setMonitorType] = useState<DomainMonitorType>('http')
const monitorsQuery = useQuery(domainMonitorsQueryOptions(domainId))
const resultsQuery = useQuery(domainMonitorResultsQueryOptions(domainId, 50))
const invalidateMonitors = async () => {
await queryClient.invalidateQueries({
queryKey: domainMonitorKeys.all,
})
}
const createMutation = useMutation({
mutationFn: () =>
createDomainMonitor(domainId, {
hostname: hostname.trim(),
type: monitorType,
}),
onSuccess: async () => {
setHostname('')
setMonitorType('http')
toast.success('Монитор создан')
await invalidateMonitors()
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось создать монитор',
)
},
})
const deleteMutation = useMutation({
mutationFn: (monitorId: number) =>
deleteDomainMonitor(domainId, monitorId),
onSuccess: async () => {
toast.success('Монитор удалён')
await invalidateMonitors()
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось удалить монитор',
)
},
})
const runMutation = useMutation({
mutationFn: () => runDomainMonitors(domainId),
onSuccess: async (data) => {
toast.success(`Проверено мониторов: ${data.checked}`)
await invalidateMonitors()
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось запустить проверку',
)
},
})
const timelineEvents = useMemo(
() =>
(resultsQuery.data ?? []).map((row) => ({
id: row.id,
hostname: row.hostname,
type: row.type,
status: row.status,
latency_ms: row.latency_ms,
error: row.error,
checked_at: row.checked_at,
})),
[resultsQuery.data],
)
const monitors = monitorsQuery.data ?? []
const canCreate = hostname.trim().length > 0 && !createMutation.isPending
return (
<div className="flex flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-sm">
Мониторы доступности hostname в зоне (HTTP, Ping, DNS)
</p>
<LoadingButton
type="button"
variant="outline"
onClick={() => runMutation.mutate()}
isLoading={runMutation.isPending}
loadingLabel="Проверка…"
disabled={monitors.length === 0}
>
Запустить проверку
</LoadingButton>
</div>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Новый монитор</FrameTitle>
<FrameDescription>
Укажите hostname и тип проверки
</FrameDescription>
</FrameHeader>
<FramePanel>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (!canCreate) return
createMutation.mutate()
}}
>
<AppFieldGroup className="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
<AppField>
<AppFieldLabel htmlFor="monitor-hostname">
Hostname
</AppFieldLabel>
<AppInput
id="monitor-hostname"
value={hostname}
placeholder="example.com"
onChange={(e) => setHostname(e.target.value)}
autoComplete="off"
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="monitor-type">Тип</AppFieldLabel>
<Select
items={MONITOR_TYPE_ITEMS}
value={monitorType}
onValueChange={(value) =>
setMonitorType((value ?? 'http') as DomainMonitorType)
}
>
<SelectTrigger id="monitor-type" className="w-full">
<SelectValue>
{monitorTypeLabel(monitorType)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{MONITOR_TYPE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</AppField>
<LoadingButton
type="submit"
isLoading={createMutation.isPending}
loadingLabel="Создание…"
disabled={!canCreate}
>
<PlusIcon data-icon="inline-start" />
Добавить
</LoadingButton>
</AppFieldGroup>
</form>
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Мониторы</FrameTitle>
<FrameDescription>
Последний статус каждой проверки
</FrameDescription>
</FrameHeader>
<FramePanel>
<QueryState
isLoading={monitorsQuery.isLoading}
isError={monitorsQuery.isError}
error={monitorsQuery.error}
onRetry={() => void monitorsQuery.refetch()}
skeleton={<TableSkeleton rows={3} cols={3} />}
>
{monitors.length === 0 ? (
<EmptyState
icon={ActivityIcon}
title="Мониторы не настроены"
description="Добавьте hostname для проверки доступности"
/>
) : (
<AppItemGroup className="gap-0">
{monitors.map((monitor, index) => (
<div key={monitor.id}>
<AppItem size="sm" variant="muted" className="border-0 px-0">
<AppItemContent className="gap-1">
<AppItemTitle className="font-mono">
{monitor.hostname}
</AppItemTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<span className="uppercase">
{monitorTypeLabel(monitor.type)}
</span>
{monitor.last_checked_at ? (
<span className="tabular-nums">
{formatDate(monitor.last_checked_at)}
</span>
) : (
<span>Ещё не проверялся</span>
)}
</div>
</AppItemContent>
<AppItemActions className="gap-2">
<HealthCheckBadge
status={monitor.last_status}
latencyMs={monitor.last_latency_ms}
lastCheckedAt={monitor.last_checked_at}
lastError={monitor.last_error}
size="xs"
showLatency
/>
<ConfirmDialog
title="Удалить монитор?"
description={`Будет удалён монитор ${monitor.hostname} (${monitorTypeLabel(monitor.type)}).`}
onConfirm={() =>
deleteMutation.mutate(monitor.id)
}
disabled={deleteMutation.isPending}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Удалить монитор ${monitor.hostname}`}
>
<Trash2Icon />
</Button>
}
/>
</AppItemActions>
</AppItem>
{index < monitors.length - 1 ? (
<AppItemSeparator />
) : null}
</div>
))}
</AppItemGroup>
)}
</QueryState>
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>История проверок</FrameTitle>
<FrameDescription>
Последние результаты мониторов зоны
</FrameDescription>
</FrameHeader>
<FramePanel>
<QueryState
isLoading={resultsQuery.isLoading}
isError={resultsQuery.isError}
error={resultsQuery.error}
onRetry={() => void resultsQuery.refetch()}
skeleton={<TableSkeleton rows={4} cols={2} />}
>
<HealthTimeline events={timelineEvents} />
</QueryState>
</FramePanel>
</Frame>
</div>
)
}
@@ -0,0 +1,130 @@
import { useState } from 'react'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Input } from '@cfdm/ui/components/input'
interface DomainsBulkToolbarProps {
count: number
isPending?: boolean
groupItems: { label: string; value: string }[]
onAssignGroup: (groupId: number | null) => void
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
onAddTag: (tag: string) => void
onClear: () => void
}
export function DomainsBulkToolbar({
count,
isPending = false,
groupItems,
onAssignGroup,
onSetEnvironment,
onAddTag,
onClear,
}: DomainsBulkToolbarProps) {
const [groupValue, setGroupValue] = useState('none')
const [envValue, setEnvValue] = useState('none')
const [tag, setTag] = useState('')
if (count === 0) return null
const envItems = [
{ label: 'Без env', value: 'none' },
{ label: 'prod', value: 'prod' },
{ label: 'staging', value: 'staging' },
{ label: 'dev', value: 'dev' },
]
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<span className="text-muted-foreground text-sm tabular-nums">
Выбрано: {count}
</span>
<Select
items={groupItems}
value={groupValue}
onValueChange={(v) => setGroupValue(v ?? 'none')}
>
<SelectTrigger className="w-44" size="sm">
<SelectValue placeholder="Группа" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
}
>
В группу
</Button>
<Select
items={envItems}
value={envValue}
onValueChange={(v) => setEnvValue(v ?? 'none')}
>
<SelectTrigger className="w-32" size="sm">
<SelectValue placeholder="Env" />
</SelectTrigger>
<SelectContent>
{envItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onSetEnvironment(
envValue === 'none'
? null
: (envValue as 'prod' | 'staging' | 'dev'),
)
}
>
Env
</Button>
<Input
className="h-8 w-32"
placeholder="тег"
value={tag}
onChange={(e) => setTag(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || !tag.trim()}
onClick={() => {
onAddTag(tag.trim())
setTag('')
}}
>
+ тег
</Button>
<Button type="button" variant="ghost" size="sm" disabled={isPending} onClick={onClear}>
Снять выделение
</Button>
</div>
)
}
+29 -10
View File
@@ -1,32 +1,41 @@
import { Badge, badgeVariants } from '@cfdm/ui/components/badge'
import type { ComponentProps } from 'react'
import { Badge } from '@/components/reui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/ui/components/tooltip'
import 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<VariantProps<typeof badgeVariants>['variant']>
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
up: 'success',
degraded: 'secondary',
down: 'destructive',
up: 'success-light',
degraded: 'warning-light',
down: 'destructive-light',
unknown: 'outline',
}
const healthLabels: Record<IpHealthStatus['status'], string> = {
up: 'OK',
degraded: 'Деград.',
degraded: 'Slow',
down: 'Down',
unknown: '—',
}
const dotColor: Record<IpHealthStatus['status'], string> = {
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({
<Tooltip>
<TooltipTrigger
render={
<span tabIndex={0} className="inline-flex cursor-default" />
<span tabIndex={0} className="inline-flex cursor-default items-center gap-1.5" />
}
>
<Badge variant={variant} className={cn('gap-1.5', className)}>
<Badge
variant={variant}
size={size}
radius="full"
className={cn('gap-1.5', className)}
>
<span
className="size-1.5 rounded-full bg-current opacity-70"
className={cn('size-1.5 shrink-0 rounded-full', dotColor[status])}
aria-hidden
/>
{label}
</Badge>
{showLatency && latencyMs != null ? (
<span className="text-muted-foreground text-xs tabular-nums">{latencyMs} мс</span>
) : null}
</TooltipTrigger>
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
@@ -0,0 +1,85 @@
import { Link2Icon } from 'lucide-react'
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import type { IpHealthStatus } from '@/lib/schemas'
import { EmptyState } from '@/components/empty-state'
export interface HealthTimelineEvent {
id: string | number
hostname?: string
type?: string
status: IpHealthStatus['status']
latency_ms?: number | null
error?: string | null
checked_at: string
}
interface HealthTimelineProps {
events: HealthTimelineEvent[]
}
export function HealthTimeline({ events }: HealthTimelineProps) {
if (events.length === 0) {
return (
<EmptyState
icon={Link2Icon}
title="Нет событий"
description="Результаты проверок появятся после первого прогона"
/>
)
}
return (
<Timeline defaultValue={1} className="gap-0">
{events.map((event, index) => {
const checkedIso =
sqliteUtcToIso(event.checked_at) ?? event.checked_at
return (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
{event.hostname ? (
<span className="font-mono text-sm">{event.hostname}</span>
) : null}
{event.type ? (
<span className="text-muted-foreground text-xs uppercase">
{event.type}
</span>
) : null}
<HealthCheckBadge
status={event.status}
latencyMs={event.latency_ms}
size="xs"
showLatency
/>
</TimelineTitle>
<TimelineDate>
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
</TimelineDate>
</TimelineHeader>
{event.error ? (
<TimelineContent>
<code className="bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs">
{event.error}
</code>
</TimelineContent>
) : null}
</TimelineItem>
)
})}
</Timeline>
)
}
@@ -98,7 +98,7 @@ export function OpsDashboard({
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Истекающие сертификаты и домены без группы
Проблемы health-check, истекающие сертификаты и домены без группы
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
@@ -67,6 +67,11 @@ export interface ResourcePageProps<T extends object> {
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<T extends object>({
emptyState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
}: ResourcePageProps<T>) {
@@ -154,11 +160,17 @@ export function ResourcePage<T extends object>({
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<T extends object>({
return (
<div ref={frameRef} className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
+43 -9
View File
@@ -1,21 +1,38 @@
import type { ComponentProps } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
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<string, string> = {
'success-light': 'bg-success',
success: 'bg-success',
'warning-light': 'bg-warning',
warning: 'bg-warning',
'destructive-light': 'bg-destructive',
destructive: 'bg-destructive',
'info-light': 'bg-info',
secondary: 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
const STATUS_LABELS: Record<string, string> = {
active: 'Активен',
synced: 'Синхронизировано',
@@ -23,12 +40,29 @@ const STATUS_LABELS: Record<string, string> = {
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 <Badge variant={variant}>{label ?? STATUS_LABELS[status] ?? status}</Badge>
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<Badge variant={variant} size="sm" radius="full" className={cn('gap-1.5', className)}>
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? STATUS_LABELS[status] ?? status}
</Badge>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { useQueries } from '@tanstack/react-query'
import { useMemo } from 'react'
import { healthStatusQueryOptions } from '@/queries'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
/** Map IP → worst health status across enabled bindings. */
export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
const enabledBindings = useMemo(
() => (bindings ?? []).filter((b) => b.health_check_enabled),
[bindings],
)
const queries = useQueries({
queries: enabledBindings.map((b) => ({
...healthStatusQueryOptions('binding', b.id),
})),
})
return useMemo(() => {
const map: Record<string, IpHealthStatus> = {}
const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const
for (const q of queries) {
for (const row of q.data ?? []) {
const prev = map[row.ip]
if (!prev || rank[row.status] > rank[prev.status]) {
map[row.ip] = row
}
}
}
return map
}, [queries])
}
+15
View File
@@ -115,6 +115,7 @@ export const domainSchema = z.object({
cf_zone_id: z.string(),
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<typeof loginSchema>
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
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,
+25
View File
@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { certificateSchema } from '@/lib/schemas'
import { z } from 'zod'
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
}
export const certificatesQueryOptions = () =>
queryOptions({
queryKey: certKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/certificates')
return z.array(certificateSchema).parse(data)
},
staleTime: 1000 * 60 * 5,
})
export const certSummaryQueryOptions = () =>
queryOptions({
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
+19
View File
@@ -0,0 +1,19 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { dnsRecordSchema } from '@/lib/schemas'
import { z } from 'zod'
export const dnsKeys = {
all: ['dns'] as const,
list: (domainId: number) => [...dnsKeys.all, domainId] as const,
}
export const dnsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: dnsKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/dns`)
return z.array(dnsRecordSchema).parse(data)
},
staleTime: 1000 * 30,
})
+111
View File
@@ -0,0 +1,111 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
domainMonitorResultSchema,
domainMonitorSchema,
ipHealthStatusSchema,
notificationLogSchema,
createDomainMonitorSchema,
} from '@/lib/schemas'
import { z } from 'zod'
type CreateDomainMonitorBody = z.input<typeof createDomainMonitorSchema>
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
export const domainMonitorKeys = {
all: ['domain-monitors'] as const,
list: (domainId: number) => [...domainMonitorKeys.all, 'list', domainId] as const,
results: (domainId: number, limit?: number) =>
[...domainMonitorKeys.all, 'results', domainId, limit] as const,
}
export const domainMonitorsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: domainMonitorKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/monitors`)
return z.array(domainMonitorSchema).parse(data)
},
})
/** Domain-scoped results include joined hostname/type from monitors. */
export const domainMonitorResultEventSchema = domainMonitorResultSchema.extend({
hostname: z.string(),
type: z.string(),
})
export type DomainMonitorResultEvent = z.infer<
typeof domainMonitorResultEventSchema
>
export const domainMonitorResultsQueryOptions = (domainId: number, limit = 50) =>
queryOptions({
queryKey: domainMonitorKeys.results(domainId, limit),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/domains/${domainId}/monitor-results?limit=${limit}`,
)
return z.array(domainMonitorResultEventSchema).parse(data)
},
})
export async function createDomainMonitor(
domainId: number,
body: CreateDomainMonitorBody,
) {
const data = await api.post<unknown>(
`/api/v1/domains/${domainId}/monitors`,
createDomainMonitorSchema.parse(body),
)
return domainMonitorSchema.parse(data)
}
export async function deleteDomainMonitor(domainId: number, monitorId: number) {
return api.delete<{ deleted: boolean }>(
`/api/v1/domains/${domainId}/monitors/${monitorId}`,
)
}
export async function runDomainMonitors(domainId: number) {
return api.post<{ checked: number }>(`/api/v1/domains/${domainId}/monitors/run`, {})
}
export const notificationLogKeys = {
all: ['notification-log'] as const,
list: (limit?: number) => [...notificationLogKeys.all, 'list', limit] as const,
}
export const notificationLogQueryOptions = (limit = 50) =>
queryOptions({
queryKey: notificationLogKeys.list(limit),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/notifications/log?limit=${limit}`)
return z.array(notificationLogSchema).parse(data)
},
})
+86
View File
@@ -0,0 +1,86 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
domainListItemSchema,
domainSchema,
subdomainSchema,
type BulkUpdateDomainsInput,
type CreateSubdomainInput,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@/lib/schemas'
import { dnsKeys } from '@/queries/dns'
import { serviceBindingKeys } from '@/queries/services'
import { z } from 'zod'
export const domainKeys = {
all: ['domains'] as const,
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
detail: (id: number) => [...domainKeys.all, 'detail', id] as const,
}
export const domainsListQueryOptions = (groupId?: number) =>
queryOptions({
queryKey: domainKeys.list(groupId),
queryFn: async () => {
const qs = groupId ? `?group_id=${groupId}` : ''
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
return z.array(domainListItemSchema).parse(data)
},
})
export const domainDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: domainKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/domains/${id}`)
return domainSchema.parse(data)
},
})
export const subdomainKeys = {
all: ['subdomains'] as const,
list: (domainId: number) => [...subdomainKeys.all, domainId] as const,
}
export const subdomainsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: subdomainKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/subdomains`)
return z.array(subdomainSchema).parse(data)
},
})
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function bulkUpdateDomains(body: BulkUpdateDomainsInput) {
return api.post<{ updated: number }>('/api/v1/domains/bulk', body)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
+27
View File
@@ -0,0 +1,27 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { groupSchema, groupWithStatsSchema } from '@/lib/schemas'
import { z } from 'zod'
export const groupKeys = {
all: ['groups'] as const,
detail: (id: number) => [...groupKeys.all, 'detail', id] as const,
}
export const groupsQueryOptions = () =>
queryOptions({
queryKey: groupKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/groups')
return z.array(groupSchema).parse(data)
},
})
export const groupDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: groupKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/groups/${id}`)
return groupWithStatsSchema.parse(data)
},
})
+6 -238
View File
@@ -1,238 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
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<unknown[]>('/api/v1/groups')
return z.array(groupSchema).parse(data)
},
})
export const groupDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: groupKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/groups/${id}`)
return groupWithStatsSchema.parse(data)
},
})
export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceViewSchema).parse(data)
},
})
export const serviceBindingKeys = {
all: ['service-bindings'] as const,
byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const,
}
export const serviceBindingsQueryOptions = () =>
queryOptions({
queryKey: serviceBindingKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/service-bindings')
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainServiceBindingsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: serviceBindingKeys.byDomain(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/service-bindings`)
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainKeys = {
all: ['domains'] as const,
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
detail: (id: number) => [...domainKeys.all, 'detail', id] as const,
}
export const domainsListQueryOptions = (groupId?: number) =>
queryOptions({
queryKey: domainKeys.list(groupId),
queryFn: async () => {
const qs = groupId ? `?group_id=${groupId}` : ''
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
return z.array(domainListItemSchema).parse(data)
},
})
export const domainDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: domainKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/domains/${id}`)
return domainSchema.parse(data)
},
})
export const dnsKeys = {
all: ['dns'] as const,
list: (domainId: number) => [...dnsKeys.all, domainId] as const,
}
export const dnsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: dnsKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/dns`)
return z.array(dnsRecordSchema).parse(data)
},
staleTime: 1000 * 30,
})
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
}
export const certificatesQueryOptions = () =>
queryOptions({
queryKey: certKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/certificates')
return z.array(certificateSchema).parse(data)
},
staleTime: 1000 * 60 * 5,
})
export const certSummaryQueryOptions = () =>
queryOptions({
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
export const subdomainKeys = {
all: ['subdomains'] as const,
list: (domainId: number) => [...subdomainKeys.all, domainId] as const,
}
export const subdomainsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: subdomainKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/subdomains`)
return z.array(subdomainSchema).parse(data)
},
})
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
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'
+73
View File
@@ -0,0 +1,73 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
} from '@/lib/schemas'
import { z } from 'zod'
export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceViewSchema).parse(data)
},
})
export const serviceBindingKeys = {
all: ['service-bindings'] as const,
byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const,
}
export const serviceBindingsQueryOptions = () =>
queryOptions({
queryKey: serviceBindingKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/service-bindings')
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainServiceBindingsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: serviceBindingKeys.byDomain(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/service-bindings`)
return z.array(serviceBindingSchema).parse(data)
},
})
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
@@ -7,7 +7,8 @@ import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { 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(() => {
@@ -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<Filter[]>(createDefaultSubdomainFilters)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(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
</Button>
<LoadingButton
variant="outline"
onClick={() => runCheckMutation.mutate()}
isLoading={runCheckMutation.isPending}
loadingLabel="Проверка…"
>
Проверить сейчас
</LoadingButton>
</>
)
@@ -289,80 +332,181 @@ function DomainOverviewPage() {
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Обзор домена и поддоменов"
description="Карточка домена"
actions={headerActions}
>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
updateDomainCertMonitoringMutation.mutate(
(value ?? 'auto') as CertMonitoring,
)
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-2">
<HealthCheckBadge
status={aggregatedHealth.status}
latencyMs={aggregatedHealth.worstLatencyMs}
lastCheckedAt={aggregatedHealth.lastCheckedAt}
lastError={aggregatedHealth.lastError}
title="Агрегат по привязкам"
showLatency
/>
{aggregatedHealth.total > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{aggregatedHealth.upCount}/{aggregatedHealth.total} OK
</span>
) : null}
</div>
</DetailPanel.Header>
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
>
<DomainBindingsCard bindings={bindings} />
</DetailPanel.Section>
<TabsList variant="line" className="gap-5">
<TabsTrigger
value="overview"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Обзор</span>
</TabsTrigger>
<TabsTrigger
value="dns"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>DNS</span>
</TabsTrigger>
<TabsTrigger
value="availability"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Доступность</span>
</TabsTrigger>
<TabsTrigger
value="subdomains"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Поддомены</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{subdomainRows.length}
</span>
</TabsTrigger>
<TabsTrigger
value="bindings"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Привязки</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{bindings.length}
</span>
</TabsTrigger>
</TabsList>
<DetailPanel.Section title="Поддомены">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSubdomainFilters())}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
<TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
title="Мониторинг SSL"
description="Настройка проверки сертификата для apex-зоны"
>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
updateDomainCertMonitoringMutation.mutate(
(value ?? 'auto') as CertMonitoring,
)
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="dns" className="flex flex-col gap-4">
<DetailPanel.Section
title="DNS-записи"
description="Управление записями зоны в Cloudflare"
>
<p className="text-muted-foreground text-sm">
Полный редактор DNS вынесен на отдельную страницу.
</p>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
Открыть DNS
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="availability" className="flex flex-col gap-4">
<DomainAvailabilityPanel domainId={id} />
</TabsContent>
<TabsContent value="subdomains" className="flex flex-col gap-4">
<DetailPanel.Section title="Поддомены">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() =>
setFilters(createDefaultSubdomainFilters())
}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<DetailPanel.Section
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
>
<DomainBindingsCard bindings={bindings} />
</DetailPanel.Section>
</TabsContent>
</Tabs>
<SubdomainEditSheet
mode={sheetMode}
+46 -10
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
@@ -6,8 +6,9 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput } from '@/lib/schemas'
import {
bulkUpdateDomains,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
@@ -23,6 +24,7 @@ import {
useDomainColumns,
useDomainFilterFields,
} from '@/components/columns/domains-columns'
import { DomainsBulkToolbar } from '@/components/domains/domains-bulk-toolbar'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
@@ -106,9 +108,21 @@ function DomainsPage() {
},
})
const bulkMutation = useMutation({
mutationFn: (body: BulkUpdateDomainsInput) => bulkUpdateDomains(body),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
toast.success(`Обновлено: ${data.updated}`)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить')
},
})
const { columns } = useDomainColumns({
onRequestDelete: setDeleteTarget,
isDeleting: deleteMutation.isPending,
enableSelection: true,
})
const handleCreate = (values: CreateDomainInput) => {
@@ -125,14 +139,9 @@ function DomainsPage() {
}
const primaryAction = (
<>
<Button type="button" variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Группы
</Button>
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
</>
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
)
return (
@@ -155,6 +164,33 @@ function DomainsPage() {
error={error}
onRetry={refetch}
primaryAction={primaryAction}
enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => (
<DomainsBulkToolbar
count={selectedIds.length}
isPending={bulkMutation.isPending}
groupItems={groupItems}
onAssignGroup={(groupId) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), group_id: groupId },
{ onSuccess: () => clearSelection() },
)
}}
onSetEnvironment={(environment) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), environment },
{ onSuccess: () => clearSelection() },
)
}}
onAddTag={(tag) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), tags_add: [tag] },
{ onSuccess: () => clearSelection() },
)
}}
onClear={clearSelection}
/>
)}
emptyState={{
title: 'Домены не импортированы',
description: 'Импортируйте зону из аккаунта Cloudflare',
+66 -4
View File
@@ -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={
<div className="grid gap-3 lg:grid-cols-2">
<div className="grid gap-3 lg:grid-cols-3">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<ActivityIcon
className="text-destructive size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Проблемы health-check</h3>
{attentionCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({attentionCount})
</span>
) : null}
</div>
{attentionDomains.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет доменов со статусом Down или Slow
</p>
) : (
<ItemGroup className="gap-2">
{attentionDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
className="hover:underline"
>
{domain.zone_name}
</Link>
</ItemTitle>
<HealthCheckBadge
status={domain.health_status ?? 'unknown'}
latencyMs={domain.health_latency_ms}
size="xs"
/>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<AlertTriangleIcon
+1266 -3
View File
File diff suppressed because it is too large Load Diff
+232 -8
View File
@@ -62,6 +62,7 @@ var domains = sqliteTable("domains", {
cf_zone_id: text("cf_zone_id").notNull(),
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,
@@ -0,0 +1,54 @@
-- Domain environment + tags + monitors + notification log
ALTER TABLE domains ADD COLUMN environment TEXT;
CREATE TABLE IF NOT EXISTS domain_tags (
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (domain_id, tag)
);
CREATE TABLE IF NOT EXISTS domain_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'http',
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 60,
timeout_ms INTEGER NOT NULL DEFAULT 5000,
path TEXT,
expected_status INTEGER,
last_status TEXT NOT NULL DEFAULT 'unknown',
last_latency_ms INTEGER,
last_checked_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_domain_monitors_domain
ON domain_monitors(domain_id);
CREATE TABLE IF NOT EXISTS domain_monitor_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER NOT NULL REFERENCES domain_monitors(id) ON DELETE CASCADE,
status TEXT NOT NULL,
latency_ms INTEGER,
error TEXT,
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_domain_monitor_results_monitor
ON domain_monitor_results(monitor_id, checked_at DESC);
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
ref_type TEXT NOT NULL,
ref_id INTEGER,
title TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notification_log_created
ON notification_log(created_at DESC);
+315 -15
View File
@@ -24,9 +24,13 @@ import { NotFoundError } from "./errors.js";
import {
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<DomainListItem>(sql`
const rows = db.all<
Omit<DomainListItem, "tags"> & { 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<typeof sql>;
} = {
group_id: groupId,
status,
environment?: string | null;
},
): Domain {
const existing = getDomain(db, id);
const updates: Record<string, unknown> = {
group_id: patch.group_id !== undefined ? patch.group_id : existing.group_id,
status: patch.status !== undefined ? patch.status : existing.status,
updated_at: sql`datetime('now')`,
};
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}
`);
}
+65
View File
@@ -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,
};
+156 -14
View File
@@ -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<typeof domainEnvironmentSchema>;
declare const domainMonitorTypeSchema: z.ZodEnum<{
http: "http";
ping: "ping";
dns: "dns";
}>;
type DomainMonitorType = z.infer<typeof domainMonitorTypeSchema>;
declare const ipHealthStateSchema: z.ZodEnum<{
unknown: "unknown";
up: "up";
@@ -294,6 +308,8 @@ declare const serviceGroupSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -340,6 +356,8 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -360,7 +378,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<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;
@@ -377,7 +395,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<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;
@@ -427,6 +445,8 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -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<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -545,6 +567,8 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -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<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -665,6 +691,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -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<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -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<z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>>;
last_synced_at: z.ZodNullable<z.ZodString>;
created_at: z.ZodString;
updated_at: z.ZodString;
@@ -832,11 +867,24 @@ declare const domainListItemSchema: z.ZodObject<{
required: "required";
skipped: "skipped";
}>>;
environment: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>>;
last_synced_at: z.ZodNullable<z.ZodString>;
created_at: z.ZodString;
updated_at: z.ZodString;
group_name: z.ZodNullable<z.ZodString>;
service_count: z.ZodNumber;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
id: z.ZodNumber;
@@ -862,6 +910,8 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_type: z.ZodCatch<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodNullable<z.ZodNumber>;
health_check_path: z.ZodNullable<z.ZodString>;
@@ -888,7 +938,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
target_ip: string | null;
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;
@@ -911,7 +961,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
target_ip: string | null;
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;
@@ -973,6 +1023,8 @@ declare const healthCheckConfigSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -997,6 +1049,8 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1081,7 +1135,89 @@ declare const updateDomainSchema: z.ZodObject<{
required: "required";
skipped: "skipped";
}>>;
environment: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>;
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
declare const bulkUpdateDomainsSchema: z.ZodObject<{
ids: z.ZodArray<z.ZodNumber>;
group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
environment: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
prod: "prod";
staging: "staging";
dev: "dev";
}>>>;
tags_add: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
type BulkUpdateDomainsInput = z.infer<typeof bulkUpdateDomainsSchema>;
declare const domainMonitorSchema: z.ZodObject<{
id: z.ZodNumber;
domain_id: z.ZodNumber;
hostname: z.ZodString;
type: z.ZodEnum<{
http: "http";
ping: "ping";
dns: "dns";
}>;
enabled: z.ZodCoercedBoolean<unknown>;
interval_sec: z.ZodNumber;
timeout_ms: z.ZodNumber;
path: z.ZodNullable<z.ZodString>;
expected_status: z.ZodNullable<z.ZodNumber>;
last_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>>;
last_latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
type DomainMonitor = z.infer<typeof domainMonitorSchema>;
declare const createDomainMonitorSchema: z.ZodObject<{
hostname: z.ZodString;
type: z.ZodEnum<{
http: "http";
ping: "ping";
dns: "dns";
}>;
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
interval_sec: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
}, z.core.$strip>;
type CreateDomainMonitorInput = z.infer<typeof createDomainMonitorSchema>;
declare const domainMonitorResultSchema: z.ZodObject<{
id: z.ZodNumber;
monitor_id: z.ZodNumber;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
error: z.ZodNullable<z.ZodString>;
checked_at: z.ZodString;
}, z.core.$strip>;
type DomainMonitorResult = z.infer<typeof domainMonitorResultSchema>;
declare const notificationLogSchema: z.ZodObject<{
id: z.ZodNumber;
kind: z.ZodString;
ref_type: z.ZodString;
ref_id: z.ZodNullable<z.ZodNumber>;
title: z.ZodString;
message: z.ZodString;
created_at: z.ZodString;
}, z.core.$strip>;
type NotificationLog = z.infer<typeof notificationLogSchema>;
type CreateSubdomainInput = z.infer<typeof createSubdomainSchema>;
type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema>;
type UpdateDomainInput = z.infer<typeof updateDomainSchema>;
@@ -1100,6 +1236,8 @@ declare const updateServiceConfigSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1124,6 +1262,8 @@ declare const createServiceGroupSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1151,6 +1291,8 @@ declare const updateServiceGroupSchema: z.ZodObject<{
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
ping: "ping";
dns: "dns";
}>>;
health_check_port: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1300,4 +1442,4 @@ declare const vpsTrackerEventSchema: z.ZodObject<{
}, z.core.$strip>;
type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;
export { type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
export { type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+67 -3
View File
@@ -161,7 +161,9 @@ function bindingToFqdn(binding) {
import { z } from "zod";
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,
+77 -1
View File
@@ -7,9 +7,15 @@ export type CertMonitoring = z.infer<typeof certMonitoringSchema>
export const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
export type LbMode = z.infer<typeof lbModeSchema>
export const healthCheckTypeSchema = z.enum(['tcp', 'http'])
export const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
export type HealthCheckType = z.infer<typeof healthCheckTypeSchema>
export const domainEnvironmentSchema = z.enum(['prod', 'staging', 'dev'])
export type DomainEnvironment = z.infer<typeof domainEnvironmentSchema>
export const domainMonitorTypeSchema = z.enum(['http', 'ping', 'dns'])
export type DomainMonitorType = z.infer<typeof domainMonitorTypeSchema>
export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown'])
export type IpHealthState = z.infer<typeof ipHealthStateSchema>
@@ -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<typeof bulkUpdateDomainsSchema>
export const domainMonitorSchema = z.object({
id: z.number(),
domain_id: z.number(),
hostname: z.string(),
type: domainMonitorTypeSchema,
enabled: z.coerce.boolean(),
interval_sec: z.number(),
timeout_ms: z.number(),
path: z.string().nullable(),
expected_status: z.number().nullable(),
last_status: ipHealthStateSchema.default('unknown'),
last_latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
export type DomainMonitor = z.infer<typeof domainMonitorSchema>
export const createDomainMonitorSchema = z.object({
hostname: z.string().min(1),
type: domainMonitorTypeSchema,
enabled: z.boolean().optional().default(true),
interval_sec: z.number().int().min(10).max(3600).optional().default(60),
timeout_ms: z.number().int().min(500).max(30000).optional().default(5000),
path: z.string().nullable().optional(),
expected_status: z.number().int().min(100).max(599).nullable().optional(),
})
export type CreateDomainMonitorInput = z.infer<typeof createDomainMonitorSchema>
export const domainMonitorResultSchema = z.object({
id: z.number(),
monitor_id: z.number(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
error: z.string().nullable(),
checked_at: z.string(),
})
export type DomainMonitorResult = z.infer<typeof domainMonitorResultSchema>
export const notificationLogSchema = z.object({
id: z.number(),
kind: z.string(),
ref_type: z.string(),
ref_id: z.number().nullable(),
title: z.string(),
message: z.string(),
created_at: z.string(),
})
export type NotificationLog = z.infer<typeof notificationLogSchema>
export type CreateSubdomainInput = z.infer<typeof createSubdomainSchema>
export type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema>
export type UpdateDomainInput = z.infer<typeof updateDomainSchema>
+9 -1
View File
@@ -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";