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
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:
+38
-7
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1266
-3
File diff suppressed because it is too large
Load Diff
Vendored
+232
-8
@@ -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
@@ -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}
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Vendored
+156
-14
@@ -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 };
|
||||
|
||||
Vendored
+67
-3
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user