Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa07f08684 | ||
|
|
2418344551 | ||
|
|
73f5380768 | ||
|
|
0ef57c673c | ||
|
|
b078fa0a03 | ||
|
|
75575a3243 | ||
|
|
5e2c301442 | ||
|
|
56cddedefe | ||
|
|
df5d5ef4ab | ||
|
|
de3dbe8521 | ||
|
|
7eb195c5d7 | ||
|
|
7f06466058 | ||
|
|
0d567379fa | ||
|
|
a228febc27 | ||
|
|
5cf39880f8 | ||
|
|
f1443f1db5 | ||
|
|
a9a84fabac | ||
|
|
2b150fa3a6 | ||
|
|
3d0ea33baf | ||
|
|
8a13888db7 | ||
|
|
f5d97e463a | ||
|
|
be8f94143f | ||
|
|
fa7d2b6df3 | ||
|
|
7938d2f707 | ||
|
|
994e79e118 | ||
|
|
87b0f1a894 |
No files matched your search
@@ -38,6 +38,10 @@ import {
|
||||
healthEngineFallbacksFromConfig,
|
||||
scheduleHealthCheckJob,
|
||||
} from "./services/health-check-scheduler.js";
|
||||
import {
|
||||
createWeightedDnsTask,
|
||||
scheduleWeightedDnsJob,
|
||||
} from "./services/weighted-dns-scheduler.js";
|
||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
|
||||
@@ -133,6 +137,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
app.decorate("reloadHealthCheckJob", () => {
|
||||
scheduleHealthCheckJob(app, config, healthTask);
|
||||
});
|
||||
scheduleWeightedDnsJob(app, createWeightedDnsTask(app));
|
||||
if (config.cloudflareApiToken) {
|
||||
fireEnsureHealthWorker(
|
||||
app.db,
|
||||
|
||||
@@ -78,6 +78,18 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/services/:id/failover-log", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
repos.getService(request.server.db, Number(id));
|
||||
return {
|
||||
items: repos.listFailoverLogForService(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
200,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/services/:id/certificates", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return certificateService.listServiceCertificates(
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos, type DnsListFilter } from "@cfdm/db";
|
||||
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
|
||||
import type {
|
||||
CfDnsRecord,
|
||||
CreateDnsRecordPayload,
|
||||
DnsRecord,
|
||||
PatchDnsRecordPayload,
|
||||
} from "@cfdm/shared";
|
||||
import {
|
||||
SYNC_CONFLICT,
|
||||
SYNC_ERROR,
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsRecordNamesMatch,
|
||||
normalizeDnsRecordName,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
@@ -64,6 +70,86 @@ function toCfPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function isMissingCfDnsRecord(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /record does not exist|81044/i.test(message);
|
||||
}
|
||||
|
||||
function dnsContentMatches(
|
||||
recordType: string,
|
||||
left: string,
|
||||
right: string,
|
||||
): boolean {
|
||||
if (recordType.toUpperCase() === "CNAME") {
|
||||
return (
|
||||
left.trim().replace(/\.+$/, "").toLowerCase() ===
|
||||
right.trim().replace(/\.+$/, "").toLowerCase()
|
||||
);
|
||||
}
|
||||
return left === right;
|
||||
}
|
||||
|
||||
/** Resolve live Cloudflare record by name + type + content (IP / CNAME target). */
|
||||
function findRemoteByIdentity(
|
||||
remote: readonly CfDnsRecord[],
|
||||
zoneName: string,
|
||||
recordType: string,
|
||||
name: string,
|
||||
content: string,
|
||||
): CfDnsRecord | undefined {
|
||||
const type = recordType.toUpperCase();
|
||||
return remote.find(
|
||||
(record) =>
|
||||
Boolean(record.id) &&
|
||||
(record.type ?? "").toUpperCase() === type &&
|
||||
dnsRecordNamesMatch(record.name, name, zoneName) &&
|
||||
dnsContentMatches(type, record.content, content),
|
||||
);
|
||||
}
|
||||
|
||||
function findRemoteByCfId(
|
||||
remote: readonly CfDnsRecord[],
|
||||
cfRecordId: string | null | undefined,
|
||||
): CfDnsRecord | undefined {
|
||||
if (!cfRecordId) return undefined;
|
||||
return remote.find((record) => record.id === cfRecordId);
|
||||
}
|
||||
|
||||
async function markSynced(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
record: DnsRecord,
|
||||
cfRec: {
|
||||
id?: string | null;
|
||||
type?: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied?: boolean | null;
|
||||
priority?: number | null;
|
||||
},
|
||||
): Promise<DnsRecord> {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
record.id,
|
||||
cfRec.type ?? record.record_type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? null,
|
||||
null,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local desired state to Cloudflare.
|
||||
* Identity is name + type + content; cf_record_id is only a cache hint
|
||||
* (records may be deleted/recreated outside CFDM).
|
||||
*/
|
||||
async function pushRecord(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -71,6 +157,7 @@ async function pushRecord(
|
||||
cfZoneId: string,
|
||||
record: DnsRecord,
|
||||
): Promise<DnsRecord> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const payload = toCfPayload(
|
||||
record.record_type,
|
||||
record.name,
|
||||
@@ -81,25 +168,38 @@ async function pushRecord(
|
||||
);
|
||||
|
||||
try {
|
||||
const cfRec = record.cf_record_id
|
||||
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
|
||||
: await cf.createDnsRecord(cfZoneId, payload);
|
||||
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
record.id,
|
||||
cfRec.type ?? record.record_type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? null,
|
||||
null,
|
||||
const remote = await cf.listDnsRecords(cfZoneId);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||
|
||||
const cfRec = targetId
|
||||
? await cf.updateDnsRecord(cfZoneId, targetId, payload)
|
||||
: await cf.createDnsRecord(cfZoneId, payload);
|
||||
return markSynced(db, domainId, record, cfRec);
|
||||
} catch (e) {
|
||||
// Race: cached id vanished mid-flight — recreate by identity.
|
||||
if (isMissingCfDnsRecord(e)) {
|
||||
try {
|
||||
const created = await cf.createDnsRecord(cfZoneId, payload);
|
||||
return markSynced(db, domainId, record, created);
|
||||
} catch (createErr) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
SYNC_ERROR,
|
||||
null,
|
||||
createErr instanceof Error ? createErr.message : String(createErr),
|
||||
);
|
||||
throw createErr;
|
||||
}
|
||||
}
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
@@ -188,15 +288,23 @@ export async function patchContent(
|
||||
): Promise<DnsRecord> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const existing = repos.getDnsRecord(db, domainId, recordId);
|
||||
if (!existing.cf_record_id) {
|
||||
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
|
||||
}
|
||||
try {
|
||||
const cfRec = await cf.patchDnsRecord(
|
||||
domain.cf_zone_id,
|
||||
existing.cf_record_id,
|
||||
payload,
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
existing.record_type,
|
||||
existing.name,
|
||||
existing.content,
|
||||
);
|
||||
const byCachedId = findRemoteByCfId(remote, existing.cf_record_id);
|
||||
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||
if (!targetId) {
|
||||
throw AppError.dnsUpdateFailed(
|
||||
"DNS-запись не найдена в Cloudflare по имени и содержимому",
|
||||
);
|
||||
}
|
||||
const cfRec = await cf.patchDnsRecord(domain.cf_zone_id, targetId, payload);
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
existing.id,
|
||||
@@ -207,7 +315,7 @@ export async function patchContent(
|
||||
cfRec.proxied ?? existing.proxied,
|
||||
cfRec.priority ?? existing.priority,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? existing.cf_record_id,
|
||||
cfRec.id ?? targetId,
|
||||
null,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, existing.id);
|
||||
@@ -235,20 +343,41 @@ export async function deleteRecord(
|
||||
const record = repos.getDnsRecord(db, domainId, recordId);
|
||||
repos.markDnsPendingDelete(db, recordId);
|
||||
|
||||
if (record.cf_record_id) {
|
||||
let targetId: string | null = null;
|
||||
try {
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
);
|
||||
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||
targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||
} catch {
|
||||
// Zone list failed — fall back to cached id only.
|
||||
targetId = record.cf_record_id;
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
try {
|
||||
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
|
||||
await cf.deleteDnsRecord(domain.cf_zone_id, targetId);
|
||||
} catch (e) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
recordId,
|
||||
SYNC_ERROR,
|
||||
record.cf_record_id,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
throw e;
|
||||
// Already gone in Cloudflare (manual delete) — drop local row.
|
||||
if (!isMissingCfDnsRecord(e)) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
recordId,
|
||||
SYNC_ERROR,
|
||||
targetId,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repos.deleteDnsRecord(db, recordId);
|
||||
}
|
||||
|
||||
@@ -326,24 +455,30 @@ export async function resolveConflict(
|
||||
}
|
||||
|
||||
if (req.source === "cloudflare") {
|
||||
if (record.cf_record_id) {
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const r = remote.find((x) => x.id === record.cf_record_id);
|
||||
if (r) {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
recordId,
|
||||
r.type,
|
||||
r.name,
|
||||
r.content,
|
||||
r.ttl,
|
||||
r.proxied ?? false,
|
||||
r.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
r.id ?? null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
);
|
||||
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||
const r = byIdentity ?? byCachedId;
|
||||
if (r) {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
recordId,
|
||||
r.type,
|
||||
r.name,
|
||||
r.content,
|
||||
r.ttl,
|
||||
r.proxied ?? false,
|
||||
r.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
r.id ?? null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
return repos.getDnsRecord(db, domainId, recordId);
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ export interface RunAllChecksOptions {
|
||||
target: HealthCheckTarget,
|
||||
prevState: IpHealthState | null,
|
||||
nextState: IpHealthState,
|
||||
) => void;
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
@@ -326,7 +326,7 @@ function logSourceResult(
|
||||
});
|
||||
}
|
||||
|
||||
function applyAggregatedStatus(
|
||||
async function applyAggregatedStatus(
|
||||
db: Db,
|
||||
target: HealthCheckTarget,
|
||||
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
|
||||
@@ -386,7 +386,8 @@ function applyAggregatedStatus(
|
||||
{ colo, provider: statusProvider },
|
||||
);
|
||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||
if (matchedNode && matchedNode.enabled) {
|
||||
// Binding-scope only: group apply must not clobber node with its own fetch failed.
|
||||
if (matchedNode && matchedNode.enabled && target.scope === "binding") {
|
||||
repos.updateNode(db, matchedNode.id, {
|
||||
health_status: node,
|
||||
consecutive_failures: failures,
|
||||
@@ -396,7 +397,7 @@ function applyAggregatedStatus(
|
||||
});
|
||||
}
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
await options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +513,7 @@ export async function runAllChecks(
|
||||
for (const source of sources) {
|
||||
logSourceResult(db, target, source.provider, source.result);
|
||||
}
|
||||
applyAggregatedStatus(db, target, sources, options);
|
||||
await applyAggregatedStatus(db, target, sources, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
import { AppError } from "../errors.js";
|
||||
import { isValidIpv4 } from "../lib/validators.js";
|
||||
import { getView } from "./service-config-service.js";
|
||||
import { selectActiveIpsByMode } from "./routing/index.js";
|
||||
import {
|
||||
isSharedPool,
|
||||
resolveDesiredAIps,
|
||||
} from "./routing/index.js";
|
||||
|
||||
function assertAddress(address: string): void {
|
||||
if (!isValidIpv4(address)) {
|
||||
@@ -89,8 +92,12 @@ export async function getOverview(
|
||||
: null;
|
||||
|
||||
const active = new Set<string>();
|
||||
const serviceIps = service.ips.filter(
|
||||
(ip) => service.ip_enabled[ip] !== false,
|
||||
);
|
||||
for (const binding of bindings) {
|
||||
const metas = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
const targetIps = metas.map((entry) => entry.ip);
|
||||
const rows = metas.map((entry) => {
|
||||
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
||||
return {
|
||||
@@ -100,12 +107,15 @@ export async function getOverview(
|
||||
health: status ? status.status : ("unknown" as const),
|
||||
};
|
||||
});
|
||||
for (const ip of selectActiveIpsByMode(
|
||||
for (const ip of resolveDesiredAIps(
|
||||
{
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
},
|
||||
rows,
|
||||
targetIps,
|
||||
Date.now(),
|
||||
serviceIps,
|
||||
)) {
|
||||
active.add(ip);
|
||||
}
|
||||
@@ -134,10 +144,13 @@ export function opsSummary(db: Db) {
|
||||
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
||||
return false;
|
||||
}
|
||||
return binding.target_ips.some((ip) => {
|
||||
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||
return row?.status === "down";
|
||||
});
|
||||
return (
|
||||
isSharedPool(binding.target_ips) &&
|
||||
binding.target_ips.some((ip) => {
|
||||
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||
return row?.status === "down";
|
||||
})
|
||||
);
|
||||
}).length;
|
||||
return {
|
||||
domains: domains.length,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
export function failoverDesired(rows: LbIpRow[]): string[] {
|
||||
if (rows.length === 0) return [];
|
||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||
const pool = healthy.length > 0 ? healthy : rows;
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
const sorted = [...pool].sort(
|
||||
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
||||
);
|
||||
const minPriority = sorted[0]!.priority;
|
||||
const primaries = sorted.filter((r) => r.priority === minPriority);
|
||||
if (healthy.length > 0) {
|
||||
if (live.length > 0) {
|
||||
return primaries.map((r) => r.ip);
|
||||
}
|
||||
return [sorted[0]!.ip];
|
||||
|
||||
@@ -3,3 +3,12 @@ import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
||||
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return state === "up" || state === "healthy";
|
||||
}
|
||||
|
||||
export function isDown(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return state === "down" || state === "unhealthy";
|
||||
}
|
||||
|
||||
/** A-pool membership: only Down is drained. Recovering (unknown/checking) and Slow return immediately. */
|
||||
export function isPoolMember(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return !isDown(state);
|
||||
}
|
||||
@@ -1,26 +1,55 @@
|
||||
import type { LbMode } from "@cfdm/shared";
|
||||
import { failoverDesired } from "./failover.js";
|
||||
import { canApplyLb, isSharedPool } from "./pool.js";
|
||||
import { roundRobinDesired } from "./round-robin.js";
|
||||
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||
import { weightedDesired } from "./weighted.js";
|
||||
|
||||
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||
export { isHealthy } from "./health.js";
|
||||
export { isDown, isHealthy, isPoolMember } from "./health.js";
|
||||
export { withBindingLock } from "./binding-lock.js";
|
||||
export {
|
||||
canApplyLb,
|
||||
isSharedPool,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
uniqueIpCount,
|
||||
} from "./pool.js";
|
||||
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
|
||||
|
||||
export function selectActiveIpsByMode(
|
||||
config: LbTargetConfig,
|
||||
rows: LbIpRow[],
|
||||
nowMs = Date.now(),
|
||||
): string[] {
|
||||
if (rows.length === 0) return [];
|
||||
if (config.lb_mode === "failover") {
|
||||
return failoverDesired(rows);
|
||||
}
|
||||
// weighted = round_robin on DNS (one A per IP)
|
||||
if (config.lb_mode === "weighted") {
|
||||
return weightedDesired(rows, nowMs);
|
||||
}
|
||||
return roundRobinDesired(rows);
|
||||
}
|
||||
|
||||
export function resolveDesiredAIps(
|
||||
config: LbTargetConfig,
|
||||
rows: LbIpRow[],
|
||||
fallbackIps: readonly string[],
|
||||
nowMs = Date.now(),
|
||||
serviceIps: readonly string[] = fallbackIps,
|
||||
): string[] {
|
||||
const fallback = [...fallbackIps];
|
||||
if (!canApplyLb(serviceIps, fallback)) return fallback;
|
||||
if (!isSharedPool(rows.map((row) => row.ip))) return fallback;
|
||||
if (config.lb_mode === "weighted" || config.health_check_enabled) {
|
||||
const activeIps = selectActiveIpsByMode(config, rows, nowMs);
|
||||
if (activeIps.length > 0) return activeIps;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function strategyLabel(mode: LbMode): string {
|
||||
if (mode === "failover") return "Failover";
|
||||
if (mode === "weighted") return "Round Robin (weighted alias)";
|
||||
if (mode === "weighted") return "Weighted";
|
||||
return "Round Robin";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { LbMode } from "@cfdm/shared";
|
||||
|
||||
export function uniqueIpCount(ips: readonly string[]): number {
|
||||
return new Set(ips.filter(Boolean)).size;
|
||||
}
|
||||
|
||||
/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */
|
||||
export function isSharedPool(ips: readonly string[]): boolean {
|
||||
return uniqueIpCount(ips) >= 2;
|
||||
}
|
||||
|
||||
/** LB / drain only when the service itself has a pool AND this FQDN is shared. */
|
||||
export function canApplyLb(
|
||||
serviceIps: readonly string[],
|
||||
bindingIps: readonly string[],
|
||||
): boolean {
|
||||
return isSharedPool(serviceIps) && isSharedPool(bindingIps);
|
||||
}
|
||||
|
||||
export function shouldRecordFailoverDnsDiff(input: {
|
||||
configuredIps: readonly string[];
|
||||
lbMode: LbMode;
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
downIps: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!isSharedPool(input.configuredIps)) return false;
|
||||
if (input.lbMode !== "weighted") return true;
|
||||
return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
export function roundRobinDesired(rows: LbIpRow[]): string[] {
|
||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||
const pool = healthy.length > 0 ? healthy : rows;
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
return pool.map((r) => r.ip);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
/** Slot length for time-sliced weighted DNS (one A at a time). */
|
||||
export const WEIGHTED_SLOT_MS = 60_000;
|
||||
|
||||
/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */
|
||||
export const WEIGHTED_DNS_TTL = 60;
|
||||
|
||||
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {
|
||||
if (rows.length === 0) return [];
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
if (pool.length === 1) return [pool[0]!.ip];
|
||||
|
||||
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
|
||||
const cycle: string[] = [];
|
||||
for (const row of sorted) {
|
||||
const weight = Math.max(1, Math.round(row.weight));
|
||||
for (let i = 0; i < weight; i++) cycle.push(row.ip);
|
||||
}
|
||||
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
|
||||
return [cycle[slot]!];
|
||||
}
|
||||
@@ -29,15 +29,91 @@ import * as domainService from "./domain-service.js";
|
||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||
import {
|
||||
isHealthy,
|
||||
canApplyLb,
|
||||
isPoolMember,
|
||||
isSharedPool,
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
withBindingLock,
|
||||
WEIGHTED_DNS_TTL,
|
||||
type LbIpRow,
|
||||
type LbTargetConfig,
|
||||
} from "./routing/index.js";
|
||||
|
||||
export type { LbIpRow, LbTargetConfig };
|
||||
export { selectActiveIpsByMode };
|
||||
export {
|
||||
canApplyLb,
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
};
|
||||
|
||||
const AUTO_DNS_TTL = 1;
|
||||
|
||||
function ttlForBinding(mode: LbMode, ips: readonly string[]): number {
|
||||
return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
||||
}
|
||||
|
||||
function enabledServiceIps(db: Db, serviceId: number): string[] {
|
||||
return repos
|
||||
.listServiceIpRows(db, serviceId)
|
||||
.filter((row) => row.enabled)
|
||||
.map((row) => row.ip);
|
||||
}
|
||||
|
||||
export function failoverARecordDiff(
|
||||
existingA: readonly string[],
|
||||
desiredIps: readonly string[],
|
||||
): { added: string[]; removed: string[] } {
|
||||
const before = new Set(existingA);
|
||||
const after = new Set(desiredIps);
|
||||
return {
|
||||
added: desiredIps.filter((ip) => !before.has(ip)),
|
||||
removed: existingA.filter((ip) => !after.has(ip)),
|
||||
};
|
||||
}
|
||||
|
||||
function recordFailoverDnsDiff(
|
||||
db: Db,
|
||||
bindingId: number,
|
||||
hostname: string,
|
||||
zoneName: string,
|
||||
existingRecords: DnsRecord[],
|
||||
desiredIps: string[],
|
||||
): void {
|
||||
const existingA = existingRecords
|
||||
.filter((record) => record.record_type.toUpperCase() === "A")
|
||||
.map((record) => record.content);
|
||||
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
|
||||
if (added.length === 0 && removed.length === 0) return;
|
||||
const binding = repos.getBinding(db, bindingId);
|
||||
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||
const { config, rows } = getBindingLbState(db, bindingId);
|
||||
const downIps = new Set(
|
||||
rows.filter((row) => row.health === "down").map((row) => row.ip),
|
||||
);
|
||||
if (
|
||||
!shouldRecordFailoverDnsDiff({
|
||||
configuredIps,
|
||||
lbMode: config.lb_mode,
|
||||
added,
|
||||
removed,
|
||||
downIps,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
repos.insertFailoverLog(db, {
|
||||
serviceId: binding.service_id,
|
||||
bindingId,
|
||||
fqdn: fqdnToDisplay(hostname, zoneName),
|
||||
entries: [
|
||||
...added.map((ip) => ({ ip, action: "added" as const })),
|
||||
...removed.map((ip) => ({ ip, action: "removed" as const })),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export interface ServiceDomainInput {
|
||||
fqdn: string;
|
||||
@@ -211,7 +287,7 @@ function getGroupLbState(
|
||||
} else {
|
||||
existing.weight += weight;
|
||||
existing.priority = Math.min(existing.priority, priority);
|
||||
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
|
||||
if (isPoolMember(existing.health) && status && !isPoolMember(status.status as IpHealthState)) {
|
||||
existing.health = status.status as IpHealthState;
|
||||
}
|
||||
}
|
||||
@@ -227,16 +303,31 @@ function getGroupLbState(
|
||||
};
|
||||
}
|
||||
|
||||
function computeActiveIps(
|
||||
function desiredAIps(
|
||||
db: Db,
|
||||
scope: HealthCheckScope,
|
||||
refId: number,
|
||||
fallbackIps: string[],
|
||||
): string[] {
|
||||
const state =
|
||||
scope === "binding"
|
||||
? getBindingLbState(db, refId)
|
||||
: getGroupLbState(db, refId);
|
||||
return selectActiveIpsByMode(state.config, state.rows);
|
||||
// Configured binding/group IPs stay intact; DNS publishes only enabled ones.
|
||||
const enabledIps =
|
||||
scope === "binding"
|
||||
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
|
||||
: fallbackIps;
|
||||
const enabledSet = new Set(enabledIps);
|
||||
const activeFallback = fallbackIps.filter((ip) => enabledSet.has(ip));
|
||||
const activeRows = state.rows.filter((row) => enabledSet.has(row.ip));
|
||||
return resolveDesiredAIps(
|
||||
state.config,
|
||||
activeRows,
|
||||
activeFallback,
|
||||
Date.now(),
|
||||
enabledIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function collectKnownZones(
|
||||
@@ -254,7 +345,38 @@ async function collectKnownZones(
|
||||
return zones;
|
||||
}
|
||||
|
||||
/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */
|
||||
function repairPoolSubsetBindings(db: Db, serviceId: number): void {
|
||||
const pool = repos.listServiceIps(db, serviceId);
|
||||
if (pool.length < 2) return;
|
||||
const poolSet = new Set(pool);
|
||||
|
||||
for (const binding of repos.listBindingsByService(db, serviceId)) {
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
if (current.length <= 1) continue;
|
||||
if (!current.every((entry) => poolSet.has(entry.ip))) continue;
|
||||
|
||||
const currentSet = new Set(current.map((entry) => entry.ip));
|
||||
if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const byIp = new Map(current.map((entry) => [entry.ip, entry]));
|
||||
repos.replaceBindingIpsWithMeta(
|
||||
db,
|
||||
binding.id,
|
||||
pool.map((ip) => ({
|
||||
ip,
|
||||
weight: byIp.get(ip)?.weight ?? 1,
|
||||
priority: byIp.get(ip)?.priority ?? 1,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
repairPoolSubsetBindings(db, serviceId);
|
||||
const service = repos.getService(db, serviceId);
|
||||
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||
const ips = ipRows.map((row) => row.ip);
|
||||
@@ -285,6 +407,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
|
||||
}
|
||||
|
||||
const { config, rows } = getBindingLbState(db, binding.id);
|
||||
const bindingActiveIps = targetCname
|
||||
? []
|
||||
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
|
||||
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
domain_id: binding.domain_id,
|
||||
@@ -312,13 +439,13 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
active_ips: bindingActiveIps,
|
||||
};
|
||||
});
|
||||
|
||||
const activeIps = new Set<string>();
|
||||
for (const binding of bindings) {
|
||||
const { config, rows } = getBindingLbState(db, binding.id);
|
||||
for (const ip of selectActiveIpsByMode(config, rows)) {
|
||||
for (const domain of domainViews) {
|
||||
for (const ip of domain.active_ips) {
|
||||
activeIps.add(ip);
|
||||
}
|
||||
}
|
||||
@@ -404,6 +531,49 @@ function fallbackCnameHealth(
|
||||
);
|
||||
}
|
||||
|
||||
function overlayLiveHealth(
|
||||
stored: IpHealthState | undefined,
|
||||
live: IpHealthState | undefined,
|
||||
): IpHealthState {
|
||||
if (live && live !== "unknown") return live;
|
||||
return stored ?? live ?? "unknown";
|
||||
}
|
||||
|
||||
function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
|
||||
if (statuses.some((status) => status === "up")) return "up";
|
||||
if (statuses.some((status) => status === "degraded")) return "degraded";
|
||||
if (statuses.some((status) => status === "down")) return "down";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Health badge applies only when the service, IP and HC (binding or group) are active. */
|
||||
function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean {
|
||||
if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) {
|
||||
return true;
|
||||
}
|
||||
if (!view.service_group_id) return false;
|
||||
const group = repos.getServiceGroup(db, view.service_group_id);
|
||||
return Boolean(group.health_check_enabled);
|
||||
}
|
||||
|
||||
function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean {
|
||||
if (!view.enabled) return false;
|
||||
if (view.ip_enabled[ip] === false) return false;
|
||||
return isServiceHealthCheckActive(db, view);
|
||||
}
|
||||
|
||||
function inactiveIpHealthRow(ip: string): ServiceHealthRow {
|
||||
return {
|
||||
ip,
|
||||
status: "unknown",
|
||||
latency_ms: null,
|
||||
last_checked_at: null,
|
||||
last_error: null,
|
||||
provider: "local",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
|
||||
function attachServiceHealth(
|
||||
db: Db,
|
||||
views: ServiceView[],
|
||||
@@ -411,10 +581,13 @@ function attachServiceHealth(
|
||||
const ids = views.map((v) => v.id);
|
||||
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||
const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids);
|
||||
return views.map((view) => {
|
||||
const health = healthByService.get(view.id);
|
||||
const rows = ipHealthByService.get(view.id) ?? [];
|
||||
const liveRows = liveByService.get(view.id) ?? [];
|
||||
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
||||
const liveByIp = new Map(liveRows.map((row) => [row.ip, row]));
|
||||
const cnameFallback = fallbackCnameHealth(rows, view);
|
||||
const aRecordIps = new Set(
|
||||
(view.domains ?? []).flatMap((domain) =>
|
||||
@@ -422,21 +595,46 @@ function attachServiceHealth(
|
||||
),
|
||||
);
|
||||
const ip_health = (view.ips ?? []).map((ip) => {
|
||||
if (!isIpHealthMonitored(db, view, ip)) {
|
||||
return inactiveIpHealthRow(ip);
|
||||
}
|
||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||
const live = liveByIp.get(ip);
|
||||
const status = overlayLiveHealth(row?.status, live?.status);
|
||||
const extras = live && live.status !== "unknown" ? live : row;
|
||||
return {
|
||||
ip,
|
||||
status: row?.status ?? ("unknown" as const),
|
||||
latency_ms: row?.latency_ms ?? null,
|
||||
last_checked_at: row?.last_checked_at ?? null,
|
||||
last_error: row?.last_error ?? null,
|
||||
provider: row?.provider ?? "local",
|
||||
colo: row?.colo ?? null,
|
||||
status,
|
||||
latency_ms: extras?.latency_ms ?? null,
|
||||
last_checked_at: extras?.last_checked_at ?? null,
|
||||
last_error:
|
||||
live && live.status !== "unknown"
|
||||
? live.last_error
|
||||
: (row?.last_error ?? null),
|
||||
provider: extras?.provider ?? "local",
|
||||
colo: extras?.colo ?? null,
|
||||
};
|
||||
});
|
||||
const monitoredStatuses = ip_health
|
||||
.filter((row) => isIpHealthMonitored(db, view, row.ip))
|
||||
.map((row) => row.status);
|
||||
const displayStatus =
|
||||
monitoredStatuses.length > 0
|
||||
? bestAliveDisplayStatus(monitoredStatuses)
|
||||
: ("unknown" as const);
|
||||
const latencyRow =
|
||||
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
|
||||
ip_health.find((row) => row.latency_ms != null);
|
||||
return {
|
||||
...view,
|
||||
health_status: health?.health_status ?? "unknown",
|
||||
health_latency_ms: health?.health_latency_ms ?? null,
|
||||
health_status:
|
||||
monitoredStatuses.length > 0
|
||||
? overlayLiveHealth(health?.health_status, displayStatus)
|
||||
: "unknown",
|
||||
health_latency_ms:
|
||||
monitoredStatuses.length > 0 && displayStatus !== "unknown"
|
||||
? (latencyRow?.latency_ms ?? null)
|
||||
: null,
|
||||
ip_health,
|
||||
};
|
||||
});
|
||||
@@ -539,26 +737,11 @@ async function syncBindingDns(
|
||||
desiredIps: string[],
|
||||
cnameTarget: string | null,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
let effectiveCname = cnameTarget?.trim() || null;
|
||||
|
||||
if (!effectiveCname) {
|
||||
const existingCname = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
);
|
||||
if (existingCname) {
|
||||
effectiveCname = existingCname.content;
|
||||
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
|
||||
repos.replaceBindingIps(db, bindingId, []);
|
||||
}
|
||||
}
|
||||
const effectiveCname = cnameTarget?.trim() || null;
|
||||
|
||||
// Desired config wins. Do NOT auto-adopt leftover CNAME from local/CF when the
|
||||
// binding is A-mode — that wiped IPs and blocked extra FQDN publishes.
|
||||
// Docs: https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
|
||||
if (effectiveCname) {
|
||||
await syncBindingCnameDns(
|
||||
db,
|
||||
@@ -571,7 +754,83 @@ async function syncBindingDns(
|
||||
return;
|
||||
}
|
||||
|
||||
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
|
||||
const binding = repos.getBinding(db, bindingId);
|
||||
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||
await syncBindingADns(
|
||||
db,
|
||||
cf,
|
||||
bindingId,
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(binding.lb_mode, configuredIps),
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete every local+CF record for hostname that must not remain in A mode. */
|
||||
async function reconcileHostnameForA(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
zoneName: string,
|
||||
hostname: string,
|
||||
desiredIps: readonly string[],
|
||||
): Promise<void> {
|
||||
const desired = new Set(desiredIps);
|
||||
|
||||
// CNAME on the same name blocks creating A records in Cloudflare.
|
||||
for (;;) {
|
||||
const cname = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
);
|
||||
if (!cname) break;
|
||||
await dnsService.deleteRecord(db, cf, domainId, cname.id);
|
||||
}
|
||||
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const staleLocal = repos
|
||||
.listDnsByDomain(db, domainId)
|
||||
.filter(
|
||||
(record) =>
|
||||
record.record_type.toUpperCase() === "A" &&
|
||||
dnsRecordNamesMatch(record.name, hostname, zoneName) &&
|
||||
!desired.has(record.content),
|
||||
);
|
||||
for (const record of staleLocal) {
|
||||
await dnsService.deleteRecord(db, cf, domainId, record.id);
|
||||
}
|
||||
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
for (const cfRec of remote) {
|
||||
if ((cfRec.type ?? "").toUpperCase() !== "A" || !cfRec.id) continue;
|
||||
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
|
||||
if (desired.has(cfRec.content)) continue;
|
||||
|
||||
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
|
||||
if (existing) {
|
||||
await dnsService.deleteRecord(db, cf, domainId, existing.id);
|
||||
continue;
|
||||
}
|
||||
const imported = repos.insertDnsRecord(
|
||||
db,
|
||||
domainId,
|
||||
cfRec.type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
"cloudflare",
|
||||
cfRec.id,
|
||||
);
|
||||
await dnsService.deleteRecord(db, cf, domainId, imported.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBindingCnameDns(
|
||||
@@ -585,6 +844,21 @@ async function syncBindingCnameDns(
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
||||
|
||||
// A/AAAA on the same name blocks CNAME create in Cloudflare.
|
||||
for (;;) {
|
||||
const conflictingA = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"A",
|
||||
);
|
||||
if (!conflictingA) break;
|
||||
await dnsService.deleteRecord(db, cf, domainId, conflictingA.id);
|
||||
}
|
||||
|
||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
@@ -658,9 +932,14 @@ async function syncBindingADns(
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
ttl: number,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
|
||||
// Align zone/local leftovers with desired A set (CNAME conflicts, stale A IPs).
|
||||
await reconcileHostnameForA(db, cf, domainId, zoneName, hostname, desiredIps);
|
||||
|
||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
@@ -677,6 +956,14 @@ async function syncBindingADns(
|
||||
|
||||
if (desiredIps.length === 0) {
|
||||
repos.setBindingDnsRecordId(db, bindingId, null);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -685,13 +972,15 @@ async function syncBindingADns(
|
||||
|
||||
for (const ip of desiredIps) {
|
||||
const existing = refreshed.find((r) => r.content === ip);
|
||||
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||
let recordId: number;
|
||||
if (existing) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||
await dnsService.update(db, cf, domainId, existing.id, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
@@ -709,12 +998,24 @@ async function syncBindingADns(
|
||||
if (adopted) {
|
||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
||||
recordId = adopted.id;
|
||||
if (
|
||||
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||
adopted.ttl !== ttl
|
||||
) {
|
||||
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||
record_type: "A",
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const record = await dnsService.create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
repos.linkBindingRecord(db, bindingId, record.id);
|
||||
@@ -725,6 +1026,14 @@ async function syncBindingADns(
|
||||
}
|
||||
|
||||
repos.setBindingDnsRecordId(db, bindingId, primaryId);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupBindingDns(
|
||||
@@ -943,12 +1252,7 @@ async function syncServiceBindingsToDns(
|
||||
}
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
|
||||
if (binding.health_check_enabled) {
|
||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
||||
if (activeIps.length > 0) {
|
||||
targetIps = activeIps;
|
||||
}
|
||||
}
|
||||
const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
|
||||
|
||||
await syncBindingDns(
|
||||
db,
|
||||
@@ -956,7 +1260,7 @@ async function syncServiceBindingsToDns(
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
targetIps,
|
||||
desiredIps,
|
||||
null,
|
||||
);
|
||||
}
|
||||
@@ -970,10 +1274,12 @@ async function collectGroupDnsIps(
|
||||
const ips: string[] = [];
|
||||
for (const service of services) {
|
||||
if (!service.enabled) continue;
|
||||
const enabled = new Set(enabledServiceIps(db, service.id));
|
||||
const bindings = repos.listBindingsByService(db, service.id);
|
||||
for (const binding of bindings) {
|
||||
for (const ip of repos.listBindingIps(db, binding.id)) {
|
||||
if (!ips.includes(ip)) ips.push(ip);
|
||||
if (!enabled.has(ip) || ips.includes(ip)) continue;
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1294,7 @@ async function syncGroupDomainDnsRecords(
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
ttl: number = AUTO_DNS_TTL,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
@@ -1003,14 +1310,16 @@ async function syncGroupDomainDnsRecords(
|
||||
if (desiredIps.length === 0) return;
|
||||
|
||||
const refreshed = repos.listGroupDnsRecords(db, groupId);
|
||||
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||
for (const ip of desiredIps) {
|
||||
const existing = refreshed.find((r) => r.content === ip);
|
||||
if (existing) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||
await dnsService.update(db, cf, domainId, existing.id, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
@@ -1026,13 +1335,25 @@ async function syncGroupDomainDnsRecords(
|
||||
);
|
||||
if (adopted) {
|
||||
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
||||
if (
|
||||
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||
adopted.ttl !== ttl
|
||||
) {
|
||||
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||
record_type: "A",
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const record = await dnsService.create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
repos.linkGroupDnsRecord(db, groupId, record.id);
|
||||
@@ -1083,9 +1404,8 @@ async function syncGroupDomainDns(
|
||||
const knownZones = await collectKnownZones(db, cf);
|
||||
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
const desiredIps = group.health_check_enabled
|
||||
? computeActiveIps(db, "group", groupId)
|
||||
: await collectGroupDnsIps(db, groupId);
|
||||
const fallbackIps = await collectGroupDnsIps(db, groupId);
|
||||
const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
|
||||
await syncGroupDomainDnsRecords(
|
||||
db,
|
||||
cf,
|
||||
@@ -1093,6 +1413,7 @@ async function syncGroupDomainDns(
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(group.lb_mode, fallbackIps),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1273,14 +1594,7 @@ export async function updateConfig(
|
||||
}
|
||||
|
||||
if (pushDns) {
|
||||
let effectiveIps = targetIps;
|
||||
const refreshedBinding = repos.getBinding(db, binding.id);
|
||||
if (refreshedBinding.health_check_enabled) {
|
||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
||||
if (activeIps.length > 0) {
|
||||
effectiveIps = activeIps;
|
||||
}
|
||||
}
|
||||
const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps);
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
@@ -1486,26 +1800,8 @@ export async function toggleServiceIp(
|
||||
repos.updateNode(db, node.id, { enabled });
|
||||
}
|
||||
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
for (const binding of bindings) {
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
const hasIp = current.some((entry) => entry.ip === ip);
|
||||
if (enabled && !hasIp) {
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
...current,
|
||||
{ ip, weight: 1, priority: 1 },
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (!enabled && hasIp) {
|
||||
repos.replaceBindingIpsWithMeta(
|
||||
db,
|
||||
binding.id,
|
||||
current.filter((entry) => entry.ip !== ip),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Keep binding IP membership stable (common FQDN = full pool). DNS sync
|
||||
// filters by enabledServiceIps via desiredAIps — do not reshuffle bindings.
|
||||
|
||||
const service = repos.getService(db, serviceId);
|
||||
if (shouldPushDns(db, service)) {
|
||||
@@ -1586,16 +1882,18 @@ export async function reconcileDnsForTarget(
|
||||
if (scope === "binding") {
|
||||
await withBindingLock(refId, async () => {
|
||||
const binding = repos.getBinding(db, refId);
|
||||
if (!binding.health_check_enabled) return;
|
||||
if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return;
|
||||
const service = repos.getService(db, binding.service_id);
|
||||
if (!shouldPushDns(db, service)) return;
|
||||
const cnameTarget = binding.cname_target?.trim() || null;
|
||||
if (cnameTarget) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
const poolIps = enabledServiceIps(db, service.id);
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const activeIps = computeActiveIps(db, "binding", refId);
|
||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
||||
const desiredIps = canApplyLb(poolIps, targetIps)
|
||||
? desiredAIps(db, "binding", refId, targetIps)
|
||||
: targetIps;
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
@@ -1610,8 +1908,61 @@ export async function reconcileDnsForTarget(
|
||||
}
|
||||
|
||||
const group = repos.getServiceGroup(db, refId);
|
||||
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
|
||||
if (!group.enabled || !group.domain?.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
|
||||
return;
|
||||
}
|
||||
await syncGroupDomainDns(db, cf, refId);
|
||||
}
|
||||
|
||||
export async function reconcileWeightedDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
): Promise<number> {
|
||||
let n = 0;
|
||||
for (const binding of repos.listAllBindings(db)) {
|
||||
if (binding.lb_mode !== "weighted") continue;
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
if (!isSharedPool(binding.target_ips ?? [])) continue;
|
||||
try {
|
||||
await withBindingLock(binding.id, async () => {
|
||||
const latest = repos.getBinding(db, binding.id);
|
||||
if (latest.lb_mode !== "weighted") return;
|
||||
if (latest.cname_target?.trim()) return;
|
||||
const service = repos.getService(db, latest.service_id);
|
||||
if (!shouldPushDns(db, service)) return;
|
||||
const targetIps = repos.listBindingIps(db, latest.id);
|
||||
const poolIps = enabledServiceIps(db, service.id);
|
||||
if (!canApplyLb(poolIps, targetIps)) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
latest.id,
|
||||
latest.domain_id,
|
||||
latest.hostname,
|
||||
desiredIps,
|
||||
null,
|
||||
);
|
||||
n += 1;
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const group of repos.listServiceGroups(db)) {
|
||||
if (group.lb_mode !== "weighted") continue;
|
||||
if (!group.enabled || !group.domain?.trim()) continue;
|
||||
try {
|
||||
await syncGroupDomainDns(db, cf, group.id);
|
||||
n += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -1,8 +1,53 @@
|
||||
import { resolve4 } from "node:dns/promises";
|
||||
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
|
||||
import type { CfdmBindingSyncItem, LbMode, ServiceBindingView } from "@cfdm/shared";
|
||||
import { isIpLiteral } from "@cfdm/shared";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
||||
import { isSharedPool } from "./routing/pool.js";
|
||||
|
||||
export function isLbMode(value: unknown): value is LbMode {
|
||||
return value === "round_robin" || value === "failover" || value === "weighted";
|
||||
}
|
||||
|
||||
/** lb_mode binding, иначе service group. */
|
||||
export function resolveLbModeForSync(
|
||||
bindingLbMode: string | undefined | null,
|
||||
groupLbMode?: string | null,
|
||||
): LbMode | undefined {
|
||||
if (isLbMode(bindingLbMode)) return bindingLbMode;
|
||||
if (isLbMode(groupLbMode)) return groupLbMode;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Effective HA only when the service has two or more unique origin IPs. */
|
||||
export function effectiveLbModeForSync(
|
||||
bindingLbMode: string | undefined | null,
|
||||
groupLbMode: string | undefined | null,
|
||||
serviceIps: readonly string[],
|
||||
): LbMode | undefined {
|
||||
if (!isSharedPool(serviceIps)) return undefined;
|
||||
return resolveLbModeForSync(bindingLbMode, groupLbMode);
|
||||
}
|
||||
|
||||
function groupLbModeForService(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
cache: Map<number, LbMode | undefined>,
|
||||
): LbMode | undefined {
|
||||
if (cache.has(serviceId)) return cache.get(serviceId);
|
||||
let mode: LbMode | undefined;
|
||||
try {
|
||||
const service = repos.getService(db, serviceId);
|
||||
if (service.service_group_id != null) {
|
||||
const group = repos.getServiceGroup(db, service.service_group_id);
|
||||
mode = resolveLbModeForSync(undefined, group.lb_mode);
|
||||
}
|
||||
} catch {
|
||||
mode = undefined;
|
||||
}
|
||||
cache.set(serviceId, mode);
|
||||
return mode;
|
||||
}
|
||||
|
||||
function fqdnToDisplay(hostname: string, zoneName: string): string {
|
||||
if (hostname === "@" || !hostname.trim()) return zoneName;
|
||||
@@ -126,10 +171,13 @@ export async function buildServiceSyncBindingsAsync(
|
||||
const allBindings = repos.listAllBindings(db);
|
||||
const index = buildBindingIndex(allBindings);
|
||||
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
||||
const groupLbCache = new Map<number, LbMode | undefined>();
|
||||
const groupLb = groupLbModeForService(db, serviceId, groupLbCache);
|
||||
|
||||
const items: CfdmBindingSyncItem[] = [];
|
||||
for (const binding of bindings) {
|
||||
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
||||
items.push({
|
||||
bindingId: binding.id,
|
||||
serviceId: service.id,
|
||||
@@ -140,6 +188,7 @@ export async function buildServiceSyncBindingsAsync(
|
||||
hostname: binding.hostname,
|
||||
ips,
|
||||
cnameTarget: cnameTargetForSync(binding),
|
||||
...(lbMode ? { lbMode } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,6 +215,7 @@ export async function buildAllSyncBindings(
|
||||
const bindings = repos.listAllBindings(db);
|
||||
const index = buildBindingIndex(bindings);
|
||||
const serviceIpCache = new Map<number, string[]>();
|
||||
const groupLbCache = new Map<number, LbMode | undefined>();
|
||||
|
||||
const items: CfdmBindingSyncItem[] = [];
|
||||
for (const binding of bindings) {
|
||||
@@ -175,6 +225,8 @@ export async function buildAllSyncBindings(
|
||||
serviceIpCache.set(binding.service_id, serviceIps);
|
||||
}
|
||||
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||
const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);
|
||||
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
||||
items.push({
|
||||
bindingId: binding.id,
|
||||
serviceId: binding.service_id,
|
||||
@@ -185,6 +237,7 @@ export async function buildAllSyncBindings(
|
||||
hostname: binding.hostname,
|
||||
ips,
|
||||
cnameTarget: cnameTargetForSync(binding),
|
||||
...(lbMode ? { lbMode } : {}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { AsyncTask, SimpleIntervalJob } from "toad-scheduler";
|
||||
import * as serviceConfigService from "./service-config-service.js";
|
||||
import { WEIGHTED_SLOT_MS } from "./routing/weighted.js";
|
||||
|
||||
export const WEIGHTED_DNS_JOB_ID = "weighted-dns";
|
||||
|
||||
export function createWeightedDnsTask(app: FastifyInstance): AsyncTask {
|
||||
return new AsyncTask(
|
||||
WEIGHTED_DNS_JOB_ID,
|
||||
async () => {
|
||||
const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);
|
||||
if (n > 0) {
|
||||
app.log.info({ reconciled: n }, "weighted dns rotated");
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
app.log.warn({ err }, "weighted dns rotate failed");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function scheduleWeightedDnsJob(
|
||||
app: FastifyInstance,
|
||||
task: AsyncTask,
|
||||
): void {
|
||||
const scheduler = app.scheduler;
|
||||
if (!scheduler) return;
|
||||
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
|
||||
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
|
||||
}
|
||||
scheduler.addSimpleIntervalJob(
|
||||
new SimpleIntervalJob(
|
||||
{ seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },
|
||||
task,
|
||||
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import { SYNC_SYNCED } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import * as dnsService from "../src/services/dns-service.js";
|
||||
import { updateConfig } from "../src/services/service-config-service.js";
|
||||
|
||||
type CfRec = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied: boolean;
|
||||
};
|
||||
|
||||
function setupDb() {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("DNS identity by name + content", () => {
|
||||
it("deletes by name+IP when cached cf_record_id is stale/missing in CF", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-live",
|
||||
type: "A",
|
||||
name: "nsgt.example.com",
|
||||
content: "130.49.213.176",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const deleted: string[] = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => [...remote],
|
||||
createDnsRecord: async () => {
|
||||
throw new Error("create should not run");
|
||||
},
|
||||
updateDnsRecord: async () => {
|
||||
throw new Error("update should not run");
|
||||
},
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
deleted.push(id);
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const local = repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"nsgt.example.com",
|
||||
"130.49.213.176",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_SYNCED,
|
||||
"local",
|
||||
"cf-stale-gone", // not present in Cloudflare
|
||||
);
|
||||
|
||||
await dnsService.deleteRecord(db, cf, domain.id, local.id);
|
||||
|
||||
expect(deleted).toEqual(["cf-live"]);
|
||||
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
|
||||
expect(remote).toEqual([]);
|
||||
});
|
||||
|
||||
it("delete is no-op success when record already removed outside CFDM", async () => {
|
||||
const db = setupDb();
|
||||
const cf = {
|
||||
listDnsRecords: async () => [],
|
||||
deleteDnsRecord: async () => {
|
||||
throw new Error("Record does not exist");
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const local = repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"nsgt.example.com",
|
||||
"130.49.213.176",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_SYNCED,
|
||||
"local",
|
||||
"cf-already-gone",
|
||||
);
|
||||
|
||||
await expect(
|
||||
dnsService.deleteRecord(db, cf, domain.id, local.id),
|
||||
).resolves.toBeUndefined();
|
||||
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("updateConfig can remove extra FQDN when CF id is stale", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-gt",
|
||||
type: "A",
|
||||
name: "gt.example.com",
|
||||
content: "130.49.213.176",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
{
|
||||
id: "cf-nsgt-live",
|
||||
type: "A",
|
||||
name: "nsgt.example.com",
|
||||
content: "130.49.213.176",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const deleted: string[] = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => [...remote],
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${remote.length}`,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
updateDnsRecord: async (
|
||||
_zoneId: string,
|
||||
id: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx < 0) throw new Error("Record does not exist");
|
||||
const rec: CfRec = {
|
||||
id,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote[idx] = rec;
|
||||
return rec;
|
||||
},
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx < 0) throw new Error("Record does not exist");
|
||||
deleted.push(id);
|
||||
remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [
|
||||
{ id: "zone-1", name: "example.com", status: "active" },
|
||||
],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const service = repos.createService(db, "Main TG", "tg-pr");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [
|
||||
{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] },
|
||||
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
|
||||
],
|
||||
});
|
||||
|
||||
// Poison cached id on extra binding's DNS row.
|
||||
const nsgtBinding = repos
|
||||
.listBindingsByService(db, service.id)
|
||||
.find((b) => b.hostname === "nsgt")!;
|
||||
const nsgtRecords = repos.listRecordsForBinding(db, nsgtBinding.id);
|
||||
for (const row of nsgtRecords) {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
row.id,
|
||||
row.record_type,
|
||||
row.name,
|
||||
row.content,
|
||||
row.ttl,
|
||||
row.proxied,
|
||||
row.priority,
|
||||
SYNC_SYNCED,
|
||||
"cf-stale-nsgt",
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] }],
|
||||
});
|
||||
|
||||
expect(view.domains.map((d) => d.fqdn)).toEqual(["gt.example.com"]);
|
||||
expect(deleted).toContain("cf-nsgt-live");
|
||||
expect(remote.some((r) => r.name.includes("nsgt"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import { SYNC_SYNCED } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import { updateConfig } from "../src/services/service-config-service.js";
|
||||
|
||||
type CfRec = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied: boolean;
|
||||
};
|
||||
|
||||
function setupDb() {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("DNS reconcile for extra FQDN", () => {
|
||||
it("publishes extra A even when local/CF already have CNAME on same name", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-cname-nsgt",
|
||||
type: "CNAME",
|
||||
name: "nsgt.example.com",
|
||||
content: "legacy.example.com",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||
const deleted: string[] = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => remote,
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
created.push(payload);
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${created.length}`,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
updateDnsRecord: async (
|
||||
_zoneId: string,
|
||||
id: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
const rec: CfRec = {
|
||||
id,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
if (idx >= 0) remote[idx] = rec;
|
||||
else remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
deleted.push(id);
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [
|
||||
{ id: "zone-1", name: "example.com", status: "active" },
|
||||
],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
// Leftover local CNAME (previous service / manual import).
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"CNAME",
|
||||
"nsgt.example.com",
|
||||
"legacy.example.com",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_SYNCED,
|
||||
"cloudflare",
|
||||
"cf-cname-nsgt",
|
||||
);
|
||||
|
||||
const service = repos.createService(db, "MSK Hip", "tg-msk-hip");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gt.example.com",
|
||||
target_ips: ["130.49.213.176"],
|
||||
},
|
||||
{
|
||||
fqdn: "nsgt.example.com",
|
||||
target_ips: ["130.49.213.176"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nsgt = view.domains.find((d) => d.fqdn === "nsgt.example.com");
|
||||
expect(nsgt?.record_type).toBe("A");
|
||||
expect(nsgt?.target_ips).toEqual(["130.49.213.176"]);
|
||||
expect(nsgt?.target_cname).toBeFalsy();
|
||||
|
||||
const binding = repos
|
||||
.listBindingsByService(db, service.id)
|
||||
.find((b) => b.hostname === "nsgt")!;
|
||||
expect(repos.listBindingIps(db, binding.id)).toEqual(["130.49.213.176"]);
|
||||
expect(deleted).toContain("cf-cname-nsgt");
|
||||
expect(
|
||||
created.some(
|
||||
(r) =>
|
||||
r.type === "A" &&
|
||||
(r.name === "nsgt" || r.name === "nsgt.example.com") &&
|
||||
r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(remote.some((r) => r.type === "CNAME" && r.name.includes("nsgt"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
remote.some(
|
||||
(r) =>
|
||||
r.type === "A" &&
|
||||
r.name.includes("nsgt") &&
|
||||
r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces stale A content for extra FQDN on the same hostname", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-stale-a",
|
||||
type: "A",
|
||||
name: "nsgt.example.com",
|
||||
content: "1.1.1.1",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const deleted: string[] = [];
|
||||
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => [...remote],
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
created.push(payload);
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${created.length}`,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
updateDnsRecord: async (
|
||||
_zoneId: string,
|
||||
id: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => ({
|
||||
id,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
}),
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
deleted.push(id);
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [
|
||||
{ id: "zone-1", name: "example.com", status: "active" },
|
||||
],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const service = repos.createService(db, "MSK", "msk");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [
|
||||
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(deleted).toContain("cf-stale-a");
|
||||
expect(
|
||||
created.some(
|
||||
(r) => r.type === "A" && r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(remote.map((r) => r.content)).toEqual(["130.49.213.176"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { failoverARecordDiff } from "../src/services/service-config-service.js";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const config = loadConfig();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: config.adminUsername, password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
describe("failoverARecordDiff", () => {
|
||||
it("diffs added and removed A contents", () => {
|
||||
expect(
|
||||
failoverARecordDiff(
|
||||
["130.49.213.153", "93.115.203.183"],
|
||||
["93.115.203.183"],
|
||||
),
|
||||
).toEqual({
|
||||
added: [],
|
||||
removed: ["130.49.213.153"],
|
||||
});
|
||||
expect(failoverARecordDiff(["10.0.0.1"], ["10.0.0.1", "10.0.0.2"])).toEqual({
|
||||
added: ["10.0.0.2"],
|
||||
removed: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /services/:id/failover-log", () => {
|
||||
it("returns add/remove rows for the service", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
|
||||
const domain = repos.createDomain(app.db, null, "rkns.top", "zone-1");
|
||||
const service = repos.createService(app.db, "MSK Hip", "msk-hip");
|
||||
const binding = repos.insertBinding(app.db, domain.id, service.id, "gt", null);
|
||||
|
||||
repos.insertFailoverLog(app.db, {
|
||||
serviceId: service.id,
|
||||
bindingId: binding.id,
|
||||
fqdn: "gt.rkns.top",
|
||||
entries: [
|
||||
{ ip: "130.49.213.153", action: "removed" },
|
||||
{ ip: "93.115.203.183", action: "added" },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${service.id}/failover-log`,
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
items: Array<{ ip: string; fqdn: string; action: string }>;
|
||||
};
|
||||
expect(body.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ip: "130.49.213.153",
|
||||
fqdn: "gt.rkns.top",
|
||||
action: "removed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ip: "93.115.203.183",
|
||||
fqdn: "gt.rkns.top",
|
||||
action: "added",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("failover_log table", () => {
|
||||
it("lists newest first", () => {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const service = repos.createService(db, "Panel", "panel");
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
|
||||
|
||||
repos.insertFailoverLog(db, {
|
||||
serviceId: service.id,
|
||||
bindingId: binding.id,
|
||||
fqdn: "panel.example.com",
|
||||
entries: [{ ip: "1.1.1.1", action: "removed" }],
|
||||
});
|
||||
repos.insertFailoverLog(db, {
|
||||
serviceId: service.id,
|
||||
bindingId: binding.id,
|
||||
fqdn: "panel.example.com",
|
||||
entries: [{ ip: "1.1.1.1", action: "added" }],
|
||||
});
|
||||
|
||||
const rows = repos.listFailoverLogForService(db, service.id);
|
||||
expect(rows.map((row) => row.action)).toEqual(["added", "removed"]);
|
||||
});
|
||||
});
|
||||
@@ -291,6 +291,77 @@ describe("health-check state derivation via runAllChecks", () => {
|
||||
expect(targets[0]?.ip).toBe("2.59.161.102");
|
||||
expect(targets[0]?.hostname).toBe("s.rkns.top");
|
||||
});
|
||||
|
||||
it("does not mark node unhealthy when binding majority is OK and group local fails", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const tcp = await startTcpServer();
|
||||
try {
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-id");
|
||||
const group = repos.createServiceGroup(
|
||||
db,
|
||||
"VPN",
|
||||
"vpn",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
{
|
||||
health_check_enabled: true,
|
||||
health_check_type: "http",
|
||||
health_check_port: 1,
|
||||
health_check_timeout_ms: 200,
|
||||
health_check_path: "/",
|
||||
},
|
||||
);
|
||||
const service = repos.createService(db, "Svc", "svc");
|
||||
repos.setServiceGroup(db, service.id, group.id);
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "@", null);
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: tcp.port,
|
||||
health_check_timeout_ms: 500,
|
||||
});
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "127.0.0.1", weight: 1, priority: 1 },
|
||||
]);
|
||||
const node = repos.findNodeByIp(db, "127.0.0.1");
|
||||
expect(node).not.toBeNull();
|
||||
|
||||
await healthCheckService.runAllChecks(db, {
|
||||
probeGapMs: 0,
|
||||
thresholds: {
|
||||
degradedFailures: 1,
|
||||
downFailures: 1,
|
||||
latencyWarnMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const bindingHealth = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"127.0.0.1",
|
||||
);
|
||||
const groupHealth = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
"group",
|
||||
group.id,
|
||||
"127.0.0.1",
|
||||
);
|
||||
const after = repos.getNode(db, node!.id);
|
||||
|
||||
expect(bindingHealth?.status).toBe("up");
|
||||
expect(groupHealth?.status).toBe("down");
|
||||
expect(after.health_status).toBe("healthy");
|
||||
expect(after.consecutive_failures).toBe(0);
|
||||
expect(after.last_failure_reason).toBeNull();
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => tcp.server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CNAME health mapped onto service IPs", () => {
|
||||
@@ -305,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
@@ -326,4 +398,157 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("getView is up when any binding IP is up", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { getView } = await import("../src/services/service-config-service.js");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "MSK Hip", "msk-hip");
|
||||
repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.2"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||
{ ip: "10.0.0.2", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"10.0.0.1",
|
||||
"up",
|
||||
12,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"10.0.0.2",
|
||||
"down",
|
||||
null,
|
||||
5,
|
||||
"timeout",
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
});
|
||||
|
||||
it("getView shows live OK over hysteresis unknown", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { getView } = await import("../src/services/service-config-service.js");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "RW Panel", "rw-panel");
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "c", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "2.59.161.102", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"2.59.161.102",
|
||||
"unknown",
|
||||
63,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
);
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: "binding",
|
||||
refId: binding.id,
|
||||
ip: "2.59.161.102",
|
||||
provider: "local",
|
||||
status: "up",
|
||||
ok: true,
|
||||
latencyMs: 63,
|
||||
colo: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
expect(view.ip_health[0]?.status).toBe("up");
|
||||
expect(view.ip_health[0]?.latency_ms).toBe(63);
|
||||
});
|
||||
|
||||
it("getView masks stale down when health-check is disabled", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { getView } = await import("../src/services/service-config-service.js");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "Main TG", "main-tg");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"130.49.213.176",
|
||||
"down",
|
||||
null,
|
||||
5,
|
||||
"timeout",
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("unknown");
|
||||
expect(view.ip_health).toEqual([
|
||||
expect.objectContaining({
|
||||
ip: "130.49.213.176",
|
||||
status: "unknown",
|
||||
latency_ms: null,
|
||||
last_error: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("getView masks stale down when IP is disabled in pool", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { getView } = await import("../src/services/service-config-service.js");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "Main TG", "main-tg");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||
repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"130.49.213.176",
|
||||
"down",
|
||||
null,
|
||||
5,
|
||||
"timeout",
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("unknown");
|
||||
expect(view.ip_health[0]?.status).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
type LbIpRow,
|
||||
type LbTargetConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js";
|
||||
|
||||
function row(
|
||||
ip: string,
|
||||
@@ -17,6 +20,11 @@ function row(
|
||||
};
|
||||
}
|
||||
|
||||
const weightedConfig: LbTargetConfig = {
|
||||
lb_mode: "weighted",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
|
||||
describe("selectActiveIpsByMode", () => {
|
||||
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
|
||||
const config: LbTargetConfig = {
|
||||
@@ -62,6 +70,18 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("failover returns recovering unknown primary immediately", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { priority: 1, health: "unknown" }),
|
||||
row("2.2.2.2", { priority: 2, health: "up" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("failover falls back to min-priority ip among all when none healthy", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
@@ -75,23 +95,61 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "weighted",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 3, health: "up" }),
|
||||
row("2.2.2.2", { weight: 1, health: "up" }),
|
||||
row("3.3.3.3", { weight: 2, health: "down" }),
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round_robin excludes unknown when another ip is up", () => {
|
||||
it("weighted excludes down ips from the cycle", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "down" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(
|
||||
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("weighted includes recovering unknown in the cycle", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "unknown" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(
|
||||
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
|
||||
).toEqual(["2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("weighted with one ip always returns that ip", () => {
|
||||
expect(
|
||||
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("weighted with all unknown rotates across every ip", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "unknown" }),
|
||||
row("2.2.2.2", { weight: 3, health: "unknown" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("weighted returns empty array for no rows", () => {
|
||||
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("round_robin puts recovering unknown back with live ips", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
@@ -100,7 +158,25 @@ describe("selectActiveIpsByMode", () => {
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "unknown" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round_robin keeps degraded in the pool with live ips", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "degraded" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array for no rows", () => {
|
||||
@@ -111,3 +187,115 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(config, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDesiredAIps", () => {
|
||||
it("keeps a dedicated single IP even when down", () => {
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
[row("1.1.1.1", { health: "down" })],
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("does not drain when the service has only one unique IP", () => {
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
[row("1.1.1.1", { health: "down" })],
|
||||
["1.1.1.1", "1.1.1.1"],
|
||||
0,
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1", "1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("does not apply overlay when service pool is a single IP", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "down" }),
|
||||
];
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
rows,
|
||||
["1.1.1.1", "2.2.2.2"],
|
||||
0,
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1", "2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("applies weighted overlay on a shared pool", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||
];
|
||||
expect(
|
||||
resolveDesiredAIps(weightedConfig, rows, ["1.1.1.1", "2.2.2.2"], 0),
|
||||
).toEqual(selectActiveIpsByMode(weightedConfig, rows, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRecordFailoverDnsDiff", () => {
|
||||
it("skips duplicate listings of the same IP", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "1.1.1.1"],
|
||||
lbMode: "failover",
|
||||
added: [],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips dedicated extra-FQDN", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1"],
|
||||
lbMode: "failover",
|
||||
added: [],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips weighted live-to-live slot swap", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||
lbMode: "weighted",
|
||||
added: ["2.2.2.2"],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("logs weighted swap when a down ip leaves the pool", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||
lbMode: "weighted",
|
||||
added: ["2.2.2.2"],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("logs failover diffs on a shared pool", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||
lbMode: "failover",
|
||||
added: ["2.2.2.2"],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import {
|
||||
listGroupViews,
|
||||
toggleServiceIp,
|
||||
updateConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
|
||||
@@ -250,7 +251,7 @@ describe("create service then list groups", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -280,6 +281,18 @@ describe("create service then list groups", () => {
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
const domainPayload = {
|
||||
lb_mode: "round_robin" as const,
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp" as const,
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
};
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4", "5.6.7.8"],
|
||||
service_group_id: group.id,
|
||||
@@ -289,23 +302,81 @@ describe("create service then list groups", () => {
|
||||
target_ips: ["1.2.3.4", "5.6.7.8"],
|
||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
...domainPayload,
|
||||
},
|
||||
{
|
||||
fqdn: "extra.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
target_ip_weights: { "1.2.3.4": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1 },
|
||||
...domainPayload,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// HTTP toggle uses request.server.cf; disable DNS push so the test
|
||||
// does not call the real Cloudflare client.
|
||||
repos.setServiceEnabled(app.db, created.id, false);
|
||||
const commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "panel")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
|
||||
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
|
||||
|
||||
expect(repos.listServiceIps(app.db, created.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(false);
|
||||
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
// DNS for common FQDN drops the disabled IP only.
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["5.6.7.8"]);
|
||||
// Per-IP extra FQDN has no enabled targets → A records removed.
|
||||
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
|
||||
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(true);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, extraBinding.id)
|
||||
.map((r) => r.content),
|
||||
).toEqual(["1.2.3.4"]);
|
||||
|
||||
// HTTP toggle still updates ip_enabled without mutating bindings.
|
||||
repos.setServiceEnabled(app.db, created.id, false);
|
||||
const offRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||
@@ -319,24 +390,117 @@ describe("create service then list groups", () => {
|
||||
};
|
||||
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
||||
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
||||
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
|
||||
|
||||
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
|
||||
|
||||
const onRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||
headers,
|
||||
payload: { ip: "1.2.3.4", enabled: true },
|
||||
});
|
||||
expect(onRes.statusCode).toBe(200);
|
||||
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
|
||||
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
|
||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const cf = mockCf();
|
||||
|
||||
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||
const group = repos.createServiceGroup(
|
||||
app.db,
|
||||
"VPN",
|
||||
"vpn-repair",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
);
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/services",
|
||||
headers,
|
||||
payload: {
|
||||
name: "Repair",
|
||||
slug: "panel-ip-repair",
|
||||
service_group_id: group.id,
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||
service_group_id: group.id,
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
},
|
||||
{
|
||||
fqdn: "extra.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
target_ip_weights: { "1.2.3.4": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1 },
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "gw")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
|
||||
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
|
||||
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
|
||||
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
||||
{ ip: "5.6.7.8", weight: 1, priority: 1 },
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
]);
|
||||
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${created.id}`,
|
||||
headers,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
const view = getRes.json() as {
|
||||
domains: Array<{ fqdn: string; target_ips: string[] }>;
|
||||
};
|
||||
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
|
||||
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
|
||||
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
|
||||
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
"9.9.9.9",
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServiceBindingView } from "@cfdm/shared";
|
||||
import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js";
|
||||
import {
|
||||
cfdmBindingSyncItemSchema,
|
||||
type ServiceBindingView,
|
||||
} from "@cfdm/shared";
|
||||
import {
|
||||
resolveBindingIpsForSync,
|
||||
resolveLbModeForSync,
|
||||
effectiveLbModeForSync,
|
||||
} from "../src/services/vps-tracker-sync.js";
|
||||
|
||||
function binding(
|
||||
partial: Partial<ServiceBindingView> &
|
||||
@@ -160,3 +167,71 @@ describe("resolveBindingIpsForSync", () => {
|
||||
expect(ips).toEqual(["203.0.113.55"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLbModeForSync", () => {
|
||||
it("prefers binding lb_mode", () => {
|
||||
expect(resolveLbModeForSync("failover", "round_robin")).toBe("failover");
|
||||
});
|
||||
|
||||
it("falls back to service group lb_mode", () => {
|
||||
expect(resolveLbModeForSync("off", "weighted")).toBe("weighted");
|
||||
expect(resolveLbModeForSync(undefined, "round_robin")).toBe("round_robin");
|
||||
});
|
||||
|
||||
it("returns undefined when neither is a known mode", () => {
|
||||
expect(resolveLbModeForSync("off", "off")).toBeUndefined();
|
||||
expect(resolveLbModeForSync(null, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveLbModeForSync", () => {
|
||||
it("omits mode when unique origin IPs are below two", () => {
|
||||
expect(
|
||||
effectiveLbModeForSync("round_robin", "failover", ["203.0.113.10"]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
effectiveLbModeForSync("round_robin", "failover", [
|
||||
"203.0.113.10",
|
||||
"203.0.113.10",
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits configured mode when the service has a pool", () => {
|
||||
expect(
|
||||
effectiveLbModeForSync("failover", "round_robin", [
|
||||
"203.0.113.10",
|
||||
"203.0.113.20",
|
||||
]),
|
||||
).toBe("failover");
|
||||
expect(
|
||||
effectiveLbModeForSync("off", "weighted", [
|
||||
"203.0.113.10",
|
||||
"198.51.100.1",
|
||||
]),
|
||||
).toBe("weighted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cfdmBindingSyncItemSchema lbMode", () => {
|
||||
const base = {
|
||||
bindingId: 1,
|
||||
serviceId: 10,
|
||||
serviceName: "VPN",
|
||||
serviceSlug: "vpn",
|
||||
fqdn: "vpn.example.com",
|
||||
zoneName: "example.com",
|
||||
hostname: "vpn",
|
||||
ips: ["203.0.113.10"],
|
||||
};
|
||||
|
||||
it("accepts optional lbMode on sync payload", () => {
|
||||
const parsed = cfdmBindingSyncItemSchema.parse({ ...base, lbMode: "failover" });
|
||||
expect(parsed.lbMode).toBe("failover");
|
||||
});
|
||||
|
||||
it("accepts payload without lbMode (legacy)", () => {
|
||||
const parsed = cfdmBindingSyncItemSchema.parse(base);
|
||||
expect(parsed.lbMode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
GlobeIcon,
|
||||
ChevronRightIcon,
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
PinIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import { useSystemAttention } from '@/hooks/use-system-attention'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@cfdm/ui/components/collapsible'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -18,25 +28,114 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
|
||||
const infrastructureNav = [
|
||||
const PINNED_KEY = 'cfdm.sidebar.pinned'
|
||||
const COLLAPSED_KEY = 'cfdm.sidebar.collapsed'
|
||||
|
||||
type NavAttention = 'services-down' | 'certs-expiring'
|
||||
|
||||
type AppNavTo =
|
||||
| '/'
|
||||
| '/domains'
|
||||
| '/services'
|
||||
| '/groups'
|
||||
| '/certificates'
|
||||
| '/settings/appearance'
|
||||
|
||||
type NavItem = {
|
||||
to: AppNavTo
|
||||
label: string
|
||||
icon: typeof GlobeIcon
|
||||
exact: boolean
|
||||
matchPrefix?: string
|
||||
attention?: NavAttention
|
||||
}
|
||||
|
||||
const infrastructureNav: NavItem[] = [
|
||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||
] as const
|
||||
]
|
||||
|
||||
const operationsNav = [
|
||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||
] as const
|
||||
const operationsNav: NavItem[] = [
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
icon: ServerIcon,
|
||||
exact: false,
|
||||
attention: 'services-down',
|
||||
},
|
||||
{
|
||||
to: '/settings/appearance',
|
||||
label: 'Настройки',
|
||||
icon: SettingsIcon,
|
||||
exact: false,
|
||||
matchPrefix: '/settings',
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryNav = [
|
||||
const secondaryNav: NavItem[] = [
|
||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
icon: ShieldCheckIcon,
|
||||
exact: false,
|
||||
attention: 'certs-expiring',
|
||||
},
|
||||
]
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{ id: 'infrastructure', label: 'Инфраструктура', items: infrastructureNav },
|
||||
{ id: 'operations', label: 'Операции', items: operationsNav },
|
||||
{ id: 'secondary', label: 'Прочее', items: secondaryNav },
|
||||
] as const
|
||||
|
||||
const NAV_ITEMS = NAV_GROUPS.flatMap((group) => group.items)
|
||||
|
||||
function readStringList(key: string) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((item): item is string => typeof item === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeStringList(key: string, values: string[]) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(values))
|
||||
} catch {
|
||||
// хранилище недоступно — состояние остаётся в памяти
|
||||
}
|
||||
}
|
||||
|
||||
function useStringList(key: string) {
|
||||
const [values, setValues] = useState<string[]>(() => readStringList(key))
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string) => {
|
||||
setValues((current) => {
|
||||
const next = current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id]
|
||||
writeStringList(key, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[key],
|
||||
)
|
||||
|
||||
return { values, toggle }
|
||||
}
|
||||
|
||||
function isNavActive(
|
||||
pathname: string,
|
||||
to: string,
|
||||
@@ -50,56 +149,148 @@ function isNavActive(
|
||||
return pathname === to || pathname.startsWith(`${to}/`)
|
||||
}
|
||||
|
||||
function NavItems({
|
||||
items,
|
||||
pathname,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
servicesDown,
|
||||
certsExpiring,
|
||||
}: {
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
pinned: readonly string[]
|
||||
onTogglePin: (to: string) => void
|
||||
servicesDown: number
|
||||
certsExpiring: number
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isPinned = pinned.includes(item.to)
|
||||
const showServicesDot = item.attention === 'services-down' && servicesDown > 0
|
||||
const showCertsDot = item.attention === 'certs-expiring' && certsExpiring > 0
|
||||
const showDot = showServicesDot || showCertsDot
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={isNavActive(pathname, item.to, item.exact, item.matchPrefix)}
|
||||
render={<Link to={item.to} activeOptions={{ exact: item.exact }} />}
|
||||
>
|
||||
<item.icon />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{showDot ? (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto size-1.5 shrink-0 rounded-full group-data-[collapsible=icon]:hidden',
|
||||
showServicesDot ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{showDot ? (
|
||||
<span className="sr-only">
|
||||
{showServicesDot ? ', есть недоступные' : ', истекают в ближайшие 14 дней'}
|
||||
</span>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
{showDot ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-1 right-1 hidden size-1.5 rounded-full group-data-[collapsible=icon]:block',
|
||||
showServicesDot ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<SidebarMenuAction
|
||||
showOnHover={!isPinned}
|
||||
aria-label={isPinned ? `Открепить «${item.label}»` : `Закрепить «${item.label}»`}
|
||||
onClick={() => onTogglePin(item.to)}
|
||||
>
|
||||
<PinIcon />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function NavSection({
|
||||
id,
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
collapsed,
|
||||
forceOpen,
|
||||
onToggleCollapsed,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
servicesDown,
|
||||
certsExpiring,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
items: readonly {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof GlobeIcon
|
||||
exact: boolean
|
||||
matchPrefix?: string
|
||||
}[]
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
collapsed: boolean
|
||||
forceOpen: boolean
|
||||
onToggleCollapsed: (id: string) => void
|
||||
pinned: readonly string[]
|
||||
onTogglePin: (to: string) => void
|
||||
servicesDown: number
|
||||
certsExpiring: number
|
||||
}) {
|
||||
const open = forceOpen || !collapsed
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={isNavActive(
|
||||
pathname,
|
||||
item.to,
|
||||
item.exact,
|
||||
item.matchPrefix,
|
||||
)}
|
||||
render={
|
||||
<Link
|
||||
to={item.to}
|
||||
activeOptions={{ exact: item.exact }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (forceOpen) return
|
||||
if (nextOpen === open) return
|
||||
onToggleCollapsed(id)
|
||||
}}
|
||||
className="group/nav-section"
|
||||
>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="w-full" render={<CollapsibleTrigger />}>
|
||||
<span>{label}</span>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'ml-auto transition-transform duration-200 motion-reduce:transition-none',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<NavItems
|
||||
items={items}
|
||||
pathname={pathname}
|
||||
pinned={pinned}
|
||||
onTogglePin={onTogglePin}
|
||||
servicesDown={servicesDown}
|
||||
certsExpiring={certsExpiring}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { state, isMobile } = useSidebar()
|
||||
const attention = useSystemAttention()
|
||||
const pins = useStringList(PINNED_KEY)
|
||||
const collapsed = useStringList(COLLAPSED_KEY)
|
||||
const forceOpen = state === 'collapsed' && !isMobile
|
||||
const pinnedItems = pins.values
|
||||
.map((to) => NAV_ITEMS.find((item) => item.to === to))
|
||||
.filter((item): item is NavItem => item != null)
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -107,9 +298,37 @@ export function AppSidebar() {
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
||||
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
||||
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
|
||||
{pinnedItems.length > 0 ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<NavItems
|
||||
items={pinnedItems}
|
||||
pathname={pathname}
|
||||
pinned={pins.values}
|
||||
onTogglePin={pins.toggle}
|
||||
servicesDown={attention.servicesDown}
|
||||
certsExpiring={attention.certsExpiring}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<NavSection
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
label={group.label}
|
||||
items={group.items}
|
||||
pathname={pathname}
|
||||
collapsed={collapsed.values.includes(group.id)}
|
||||
forceOpen={forceOpen}
|
||||
onToggleCollapsed={collapsed.toggle}
|
||||
pinned={pins.values}
|
||||
onTogglePin={pins.toggle}
|
||||
servicesDown={attention.servicesDown}
|
||||
certsExpiring={attention.certsExpiring}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Frame } from "@/components/reui/frame"
|
||||
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { ICard } from "./data"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<a
|
||||
href={card.href}
|
||||
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full w-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
|
||||
>
|
||||
<FramePanel className="space-y-7.5 p-3.5">
|
||||
{card.icon}
|
||||
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
|
||||
{card.title}
|
||||
</h3>
|
||||
</FramePanel>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { ActivityIcon, BookOpenIcon, ShieldIcon, CircleDollarSignIcon, BarChart3Icon, TrendingUp } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
title: string
|
||||
href: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
title: "Valuation",
|
||||
href: "#valuation",
|
||||
icon: (
|
||||
<ActivityIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Ledger",
|
||||
href: "#ledger",
|
||||
icon: (
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Funding",
|
||||
href: "#funding",
|
||||
icon: (
|
||||
<ShieldIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Cash Flow",
|
||||
href: "#cash-flow",
|
||||
icon: (
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Metrics",
|
||||
href: "#metrics",
|
||||
icon: (
|
||||
<BarChart3Icon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Earnings",
|
||||
href: "#earnings",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-lg items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type ToolbarOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
export const EXPORT_AUDIENCE_OPTIONS = [
|
||||
{ value: "everyone", label: "Everyone" },
|
||||
{ value: "ops-leads", label: "Ops leads" },
|
||||
{ value: "finance-reviewers", label: "Finance reviewers" },
|
||||
{ value: "client-owners", label: "Client owners" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_SCOPE_OPTIONS = [
|
||||
{ value: "all-workspaces", label: "All workspaces" },
|
||||
{ value: "harbor-field", label: "Harbor field" },
|
||||
{ value: "market-lab", label: "Market lab" },
|
||||
{ value: "support-desk", label: "Support desk" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_RANGE_OPTIONS = [
|
||||
{ value: "last-30-days", label: "Last 30 days" },
|
||||
{ value: "this-quarter", label: "This quarter" },
|
||||
{ value: "previous-cycle", label: "Previous cycle" },
|
||||
{ value: "custom-window", label: "Custom window" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_FORMAT_OPTIONS = [
|
||||
{
|
||||
value: "csv",
|
||||
label: "CSV bundle",
|
||||
description: "Spreadsheet-ready activity rows",
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: "PDF brief",
|
||||
description: "A concise review packet for stakeholders",
|
||||
},
|
||||
{
|
||||
value: "schedule",
|
||||
label: "Schedule delivery",
|
||||
description: "Send this export every Friday morning",
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
|
||||
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
|
||||
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
|
||||
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
} from "@cfdm/ui/components/button-group"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@cfdm/ui/components/empty"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
EXPORT_AUDIENCE_OPTIONS,
|
||||
EXPORT_FORMAT_OPTIONS,
|
||||
EXPORT_RANGE_OPTIONS,
|
||||
EXPORT_SCOPE_OPTIONS,
|
||||
type ExportAudience,
|
||||
type ExportFormat,
|
||||
type ExportRange,
|
||||
type ExportScope,
|
||||
type ToolbarOption,
|
||||
} from "./data"
|
||||
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
type ToolbarFilterProps<T extends string> = {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly ToolbarOption<T>[]
|
||||
onValueChange: (value: T) => void
|
||||
}
|
||||
|
||||
function getOptionLabel<T extends string>(
|
||||
options: readonly ToolbarOption<T>[],
|
||||
value: T
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function ToolbarFilter<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
}: ToolbarFilterProps<T>) {
|
||||
const selectedLabel = getOptionLabel(options, value)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`${label}: ${selectedLabel}`}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue !== null) {
|
||||
onValueChange(nextValue as T)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
closeOnClick
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
const [audience, setAudience] = useState<ExportAudience>(
|
||||
EXPORT_AUDIENCE_OPTIONS[0].value
|
||||
)
|
||||
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
|
||||
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
|
||||
|
||||
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
|
||||
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
|
||||
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
|
||||
|
||||
const showExportToast = (format: ExportFormat = "csv") => {
|
||||
const formatOption =
|
||||
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
|
||||
EXPORT_FORMAT_OPTIONS[0]
|
||||
|
||||
toast.message(`${formatOption.label} is ready to wire`, {
|
||||
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-[430px] w-full max-w-4xl flex-col"
|
||||
aria-labelledby="export-ledger-heading"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
{/* Heading */}
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{/* Title and Description */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* Title */}
|
||||
<h2
|
||||
id="export-ledger-heading"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
Activity Exports
|
||||
</h2>
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Download scoped activity packets for billing review, staffing
|
||||
audits, and client handoffs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ToolbarFilter
|
||||
label="Audience"
|
||||
value={audience}
|
||||
options={EXPORT_AUDIENCE_OPTIONS}
|
||||
onValueChange={setAudience}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Workspace"
|
||||
value={scope}
|
||||
options={EXPORT_SCOPE_OPTIONS}
|
||||
onValueChange={setScope}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Date range"
|
||||
value={range}
|
||||
options={EXPORT_RANGE_OPTIONS}
|
||||
onValueChange={setRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Action */}
|
||||
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none"
|
||||
onClick={() => showExportToast()}
|
||||
>
|
||||
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
|
||||
<ButtonGroupSeparator className="bg-primary/72" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="border-primary-foreground/20 rounded-l-none border-l"
|
||||
aria-label="Open download options"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => showExportToast("csv")}>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
CSV bundle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
PDF brief
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
|
||||
<CalendarClockIcon aria-hidden="true" />
|
||||
Schedule delivery
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
|
||||
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden="true">
|
||||
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
|
||||
{/* Empty State Content */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
No exportable activity yet
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||
Capture approved activity and this view will assemble your next
|
||||
review packet.
|
||||
</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Activity exports empty state
|
||||
</h1>
|
||||
<EmptyState />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import { FieldLegend, FieldSet } from "@cfdm/ui/components/field"
|
||||
import { ACCENT_COLORS } from "./data"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
// ── Color Picker ──
|
||||
|
||||
export function ColorPicker() {
|
||||
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||
|
||||
return (
|
||||
<FieldSet className="gap-0">
|
||||
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
aria-label={color.name}
|
||||
onClick={() => setSelected(color.value)}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||
selected === color.value &&
|
||||
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
{selected === color.value ? (
|
||||
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type Preference = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
}
|
||||
|
||||
export type AccentColorOption = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingBadgeVariant =
|
||||
| "primary-light"
|
||||
| "destructive-light"
|
||||
| "info-light"
|
||||
|
||||
export type SettingFieldProps = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: { label: string; variant: SettingBadgeVariant }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
}
|
||||
|
||||
export type DateFormatOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const DAYS: SelectOption[] = [
|
||||
{ value: "monday", label: "Monday" },
|
||||
{ value: "tuesday", label: "Tuesday" },
|
||||
{ value: "wednesday", label: "Wednesday" },
|
||||
{ value: "thursday", label: "Thursday" },
|
||||
{ value: "friday", label: "Friday" },
|
||||
{ value: "saturday", label: "Saturday" },
|
||||
{ value: "sunday", label: "Sunday" },
|
||||
]
|
||||
|
||||
export const CURRENCIES: SelectOption[] = [
|
||||
{ value: "usd", label: "USD · US Dollar" },
|
||||
{ value: "eur", label: "EUR · Euro" },
|
||||
{ value: "gbp", label: "GBP · British Pound" },
|
||||
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||
]
|
||||
|
||||
export const REGIONS: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "eu", label: "European Union" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "jp", label: "Japan" },
|
||||
{ value: "au", label: "Australia" },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||
{ name: "Slate", value: "#64748b" },
|
||||
{ name: "Rose", value: "#f43f5e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Emerald", value: "#10b981" },
|
||||
{ name: "Sky", value: "#0ea5e9" },
|
||||
]
|
||||
|
||||
export const PREFERENCES: Preference[] = [
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
label: "Enable keyboard shortcuts.",
|
||||
description: "Use shortcuts to speed up your workflow.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "spellcheck",
|
||||
label: "Auto spell-check.",
|
||||
description: "Highlight spelling errors in text fields.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "compact-mode",
|
||||
label: "Compact Mode.",
|
||||
description: "Reduce spacing for a denser interface layout.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "animations",
|
||||
label: "Reduce animations.",
|
||||
description: "Minimize motion effects throughout the UI.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
|
||||
import { PREFERENCES } from "./data"
|
||||
|
||||
// ── Editor Preferences ──
|
||||
|
||||
export function EditorPreferences() {
|
||||
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">
|
||||
Configure editing behavior and interface density.
|
||||
</FieldDescription>
|
||||
|
||||
{/* List */}
|
||||
<FieldGroup className="gap-3">
|
||||
{PREFERENCES.map((pref) => (
|
||||
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||
<Switch
|
||||
id={`settings-3-${pref.id}`}
|
||||
checked={values[pref.id]}
|
||||
onCheckedChange={(value) =>
|
||||
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
|
||||
<FieldContent className="gap-0.5">
|
||||
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||
{pref.label}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{pref.description}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@cfdm/ui/components/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@cfdm/ui/components/toggle-group"
|
||||
import { ColorPicker } from "./color-picker"
|
||||
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||
import { EditorPreferences } from "./editor-preferences"
|
||||
import { SettingField } from "./setting-field"
|
||||
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export function GeneralSettings() {
|
||||
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||
const [region, setRegion] = useState("us")
|
||||
const [currency, setCurrency] = useState("usd")
|
||||
const handleStartOfWeekChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setStartOfWeek(value)
|
||||
}
|
||||
}
|
||||
const handleRegionChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setRegion(value)
|
||||
}
|
||||
}
|
||||
const handleCurrencyChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setCurrency(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-3xl">
|
||||
<FrameHeader className="px-2! py-2.5!">
|
||||
<FrameTitle>General Settings</FrameTitle>
|
||||
<FrameDescription>Core app preferences.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
{/* ── Project name ── */}
|
||||
<SettingField
|
||||
title="Project name"
|
||||
description="The display name for your project across the platform."
|
||||
labelFor="settings-3-project-name"
|
||||
>
|
||||
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||
</SettingField>
|
||||
|
||||
{/* ── API endpoint ── */}
|
||||
<SettingField
|
||||
title="API endpoint"
|
||||
description="Set the base URL for your project API."
|
||||
badge={{ label: "Required", variant: "destructive-light" }}
|
||||
labelFor="settings-3-api-endpoint"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-3-api-endpoint"
|
||||
defaultValue="api.acme.io"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Start of week ── */}
|
||||
<SettingField
|
||||
title="Start of week"
|
||||
description="Choose which day marks the start of your week."
|
||||
labelFor="settings-3-start-of-week"
|
||||
>
|
||||
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DAYS.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Allowed origins ── */}
|
||||
<SettingField
|
||||
title="Allowed origins"
|
||||
description="Define the trusted domains for CORS requests."
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add origin
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<UploadIcon aria-hidden="true" />
|
||||
Import from file
|
||||
</Button>
|
||||
</div>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Accent color ── */}
|
||||
<SettingField
|
||||
title="Accent color"
|
||||
description="Select a color to represent your brand."
|
||||
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||
>
|
||||
<ColorPicker />
|
||||
</SettingField>
|
||||
|
||||
{/* ── Region & currency ── */}
|
||||
<SettingField
|
||||
title="Region & currency"
|
||||
description="Adjust your regional preferences and currency."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Regional and financial formatting preferences.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||
<Select value={region} onValueChange={handleRegionChange}>
|
||||
<SelectTrigger id="settings-3-region" className="w-full">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(REGIONS, region)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-currency">
|
||||
Currency
|
||||
</FieldLabel>
|
||||
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(CURRENCIES, currency)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.value}
|
||||
value={currency.value}
|
||||
>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Date display format ── */}
|
||||
<SettingField
|
||||
title="Date display format"
|
||||
description="Choose your preferred format for dates."
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setDateFormat(value)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Date display format"
|
||||
>
|
||||
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Editor preferences ── */}
|
||||
<SettingField
|
||||
title="Editor preferences"
|
||||
description="Adjust your editing environment and display options."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<EditorPreferences />
|
||||
</SettingField>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save changes</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
function getOptionLabel(
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@cfdm/ui/components/field"
|
||||
|
||||
interface SettingFieldProps {
|
||||
title: string
|
||||
description: string
|
||||
badge?: {
|
||||
label: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
// ── Setting Field ──
|
||||
|
||||
export function SettingField({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
}: SettingFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralSettings } from "./components/general-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface CardData {
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
value: string | number
|
||||
label: string
|
||||
info: ReactNode
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const cards: CardData[] = [
|
||||
{
|
||||
icon: (
|
||||
<Headphones aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-blue-600 dark:text-blue-400",
|
||||
value: 320,
|
||||
label: "Support Tickets",
|
||||
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-emerald-600 dark:text-emerald-400",
|
||||
value: "98%",
|
||||
label: "Resolved",
|
||||
info: <Badge variant="success-light">+2.1% this month</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<SmileIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-amber-600 dark:text-amber-400",
|
||||
value: "4.8",
|
||||
label: "Satisfaction Rate",
|
||||
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||
|
||||
import { cards } from "./data"
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<div className="@container w-full grow">
|
||||
{/* Grid */}
|
||||
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
|
||||
{cards.map((card, i) => (
|
||||
<Frame key={i}>
|
||||
<FramePanel className="flex flex-col items-start gap-6">
|
||||
<Item
|
||||
className={cn(
|
||||
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{card.info}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stats } from "./components/stats"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Stats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ interface ConfirmDialogProps {
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/** Кнопка подтверждения. Удаление остаётся destructive. */
|
||||
confirmVariant?: 'default' | 'destructive'
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -31,6 +33,7 @@ export function ConfirmDialog({
|
||||
description,
|
||||
confirmLabel = 'Удалить',
|
||||
cancelLabel = 'Отмена',
|
||||
confirmVariant = 'destructive',
|
||||
onConfirm,
|
||||
disabled,
|
||||
}: ConfirmDialogProps) {
|
||||
@@ -46,7 +49,11 @@ export function ConfirmDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
<AlertDialogAction
|
||||
variant={confirmVariant}
|
||||
onClick={onConfirm}
|
||||
disabled={disabled}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { FolderTreeIcon, GlobeIcon, Link2Icon, ServerIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
SubdomainEditSheet,
|
||||
type SubdomainEditValues,
|
||||
} from '@/components/subdomain-edit-sheet'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { useDomainPage } from '@/hooks/use-domain-page'
|
||||
import {
|
||||
type DomainDetailTab,
|
||||
} from '@/components/domains/domain-detail-tabs'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
import {
|
||||
dnsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
groupKeys,
|
||||
domainKeys,
|
||||
updateDomain,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
|
||||
const groupFormSchema = z.object({
|
||||
group_id: z.string(),
|
||||
})
|
||||
|
||||
type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||
|
||||
interface DomainDetailActions {
|
||||
openCreateSubdomain: () => void
|
||||
}
|
||||
|
||||
function DomainChromeSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TableSkeleton rows={1} cols={2} />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Общая шапка, KPI и полоса вкладок карточки домена.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Preview: https://reui.io/preview/base/form-7
|
||||
* Preview: https://reui.io/preview/base/settings-16
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function DomainDetailChrome({
|
||||
domainId,
|
||||
activeTab,
|
||||
children,
|
||||
}: {
|
||||
domainId: string
|
||||
activeTab: DomainDetailTab
|
||||
children: (actions: DomainDetailActions) => ReactNode
|
||||
}) {
|
||||
const id = Number(domainId)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const page = useDomainPage(id)
|
||||
const groupsQuery = useQuery(groupsQueryOptions())
|
||||
const dnsQuery = useQuery(dnsListQueryOptions(id))
|
||||
|
||||
const [groupOpen, setGroupOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [changeIp, setChangeIp] = useState<ServiceBinding | null>(null)
|
||||
|
||||
const domain = page.domain
|
||||
const groupForm = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupFormSchema),
|
||||
defaultValues: { group_id: 'none' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupOpen || !domain) return
|
||||
groupForm.reset({
|
||||
group_id: domain.group_id != null ? String(domain.group_id) : 'none',
|
||||
})
|
||||
}, [groupOpen, domain, groupForm])
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
if (!domain?.group_id) return null
|
||||
return groupsQuery.data?.find((group) => group.id === domain.group_id)?.name ?? null
|
||||
}, [domain, groupsQuery.data])
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
{ label: 'Без группы', value: 'none' },
|
||||
...(groupsQuery.data ?? []).map((group) => ({
|
||||
label: group.name,
|
||||
value: String(group.id),
|
||||
})),
|
||||
],
|
||||
[groupsQuery.data],
|
||||
)
|
||||
|
||||
const updateGroupMutation = useMutation({
|
||||
mutationFn: (groupId: number | null) => updateDomain(id, { group_id: groupId }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.detail(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.all }),
|
||||
])
|
||||
setGroupOpen(false)
|
||||
toast.success('Группа домена обновлена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сменить группу')
|
||||
},
|
||||
})
|
||||
|
||||
function openCreateSubdomain() {
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
async function handleCreateSubdomain(values: SubdomainEditValues) {
|
||||
await page.createSubdomainMutation.mutateAsync(values.name)
|
||||
setCreateOpen(false)
|
||||
}
|
||||
|
||||
function handleTabChange(next: string) {
|
||||
if (next === 'dns') {
|
||||
void navigate({
|
||||
to: '/domains/$domainId/dns',
|
||||
params: { domainId },
|
||||
search: { host: undefined },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
next === 'overview' ||
|
||||
next === 'availability' ||
|
||||
next === 'subdomains' ||
|
||||
next === 'bindings'
|
||||
) {
|
||||
void navigate({
|
||||
to: '/domains/$domainId',
|
||||
params: { domainId },
|
||||
search: { tab: next },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const kpiCards = domain
|
||||
? [
|
||||
{
|
||||
id: 'status',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'Статус',
|
||||
value: domain.status,
|
||||
hint: <StatusBadge status={domain.status} />,
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
icon: <FolderTreeIcon />,
|
||||
label: 'Группа',
|
||||
value: groupName ?? '—',
|
||||
hint: groupName ? 'В группе' : 'Без группы',
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
icon: <Link2Icon />,
|
||||
label: 'Привязки',
|
||||
value: page.bindings.length,
|
||||
hint: 'Сервисы в зоне',
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Синхронизация',
|
||||
value: formatDate(domain.last_synced_at),
|
||||
hint: 'Последняя синхронизация',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const bindings = page.bindings
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={page.isLoading}
|
||||
isError={page.isError}
|
||||
error={page.error}
|
||||
onRetry={page.refetch}
|
||||
skeleton={<DomainChromeSkeleton />}
|
||||
>
|
||||
{domain ? (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={domain.zone_name}
|
||||
description="Карточка домена"
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => setGroupOpen(true)}>
|
||||
Сменить группу
|
||||
</Button>
|
||||
{bindings.length === 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setChangeIp(bindings[0] ?? null)}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
) : bindings.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
Сменить IP
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{bindings.map((binding) => (
|
||||
<DropdownMenuItem
|
||||
key={binding.id}
|
||||
onClick={() => setChangeIp(binding)}
|
||||
>
|
||||
{binding.hostname}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button type="button" onClick={openCreateSubdomain}>
|
||||
Добавить поддомен
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} />
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'availability', label: 'Доступность' },
|
||||
{
|
||||
id: 'subdomains',
|
||||
label: 'Поддомены',
|
||||
count: page.subdomainRows.length,
|
||||
},
|
||||
{ id: 'bindings', label: 'Привязки', count: bindings.length },
|
||||
{
|
||||
id: 'dns',
|
||||
label: 'DNS',
|
||||
count: dnsQuery.data ? dnsQuery.data.length : undefined,
|
||||
},
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
{activeTab === 'overview' ? <DomainOverviewFacts domainId={id} /> : null}
|
||||
{children({ openCreateSubdomain })}
|
||||
</CountedLineTabs>
|
||||
|
||||
<FormSheet
|
||||
open={groupOpen}
|
||||
onOpenChange={setGroupOpen}
|
||||
title="Сменить группу"
|
||||
description={`Назначить группу для ${domain.zone_name}`}
|
||||
form={groupForm}
|
||||
onSubmit={(values) => {
|
||||
const next = values.group_id === 'none' ? null : Number(values.group_id)
|
||||
updateGroupMutation.mutate(next)
|
||||
}}
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={updateGroupMutation.isPending}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple label="Группа" htmlFor="domain-group-id">
|
||||
<Controller
|
||||
control={groupForm.control}
|
||||
name="group_id"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="domain-group-id" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
|
||||
<SubdomainEditSheet
|
||||
mode="create"
|
||||
subdomain={null}
|
||||
zoneName={domain.zone_name}
|
||||
services={page.services}
|
||||
serviceGroupById={page.serviceGroupById}
|
||||
currentServiceId="none"
|
||||
open={createOpen}
|
||||
isSaving={page.createSubdomainMutation.isPending}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreateSubdomain}
|
||||
/>
|
||||
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.id ?? null}
|
||||
serviceId={changeIp?.service_id ?? null}
|
||||
currentIp={changeIp?.target_ip ?? changeIp?.target_ips?.[0]}
|
||||
/>
|
||||
</DetailPanel>
|
||||
) : null}
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainOverviewFacts({ domainId }: { domainId: number }) {
|
||||
const page = useDomainPage(domainId)
|
||||
const groupsQuery = useQuery(groupsQueryOptions())
|
||||
const domain = page.domain
|
||||
if (!domain) return null
|
||||
|
||||
const groupName = domain.group_id
|
||||
? groupsQuery.data?.find((group) => group.id === domain.group_id)?.name
|
||||
: null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Зона</FrameTitle>
|
||||
<FrameDescription>Параметры зоны Cloudflare</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<SettingRow title="Зона">
|
||||
<span className="text-sm font-medium">{domain.zone_name}</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Группа">
|
||||
{domain.group_id ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/groups/$groupId" params={{ groupId: String(domain.group_id) }} />
|
||||
}
|
||||
>
|
||||
{groupName ?? 'Открыть группу'}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
)}
|
||||
</SettingRow>
|
||||
<SettingRow title="Zone ID">
|
||||
<span className="font-mono text-sm break-all">{domain.cf_zone_id}</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Последняя синхронизация" last>
|
||||
<span className="text-sm tabular-nums">{formatDate(domain.last_synced_at)}</span>
|
||||
</SettingRow>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DOMAIN_SECTION_TABS = [
|
||||
'overview',
|
||||
'availability',
|
||||
'subdomains',
|
||||
'bindings',
|
||||
] as const
|
||||
|
||||
export type DomainSectionTab = (typeof DOMAIN_SECTION_TABS)[number]
|
||||
export type DomainDetailTab = DomainSectionTab | 'dns'
|
||||
@@ -9,15 +9,11 @@ import {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -26,23 +22,12 @@ export function DomainsBulkToolbar({
|
||||
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 (
|
||||
<Frame dense spacing="sm" className="mb-3 w-full">
|
||||
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
|
||||
@@ -74,56 +59,16 @@ export function DomainsBulkToolbar({
|
||||
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'),
|
||||
)
|
||||
}
|
||||
onClick={() => onAssignGroup(null)}
|
||||
>
|
||||
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"
|
||||
|
||||
@@ -1,40 +1,161 @@
|
||||
import { ShieldCheckIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
import {
|
||||
failoverEventCopy,
|
||||
type FailoverEvent,
|
||||
type FailoverHistoryItem,
|
||||
} from '@/lib/failover-events'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
export interface FailoverEvent {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
|
||||
|
||||
function failStreakLabel(count: number): string {
|
||||
const mod10 = count % 10
|
||||
const mod100 = count % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} ошибка подряд`
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||||
return `${count} ошибки подряд`
|
||||
}
|
||||
return `${count} ошибок подряд`
|
||||
}
|
||||
|
||||
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
function indicatorClass(tone: 'down' | 'added' | 'removed'): string {
|
||||
if (tone === 'added') {
|
||||
return 'border-success bg-success/15 group-data-completed/timeline-item:border-success'
|
||||
}
|
||||
return 'border-destructive bg-destructive/15 group-data-completed/timeline-item:border-destructive'
|
||||
}
|
||||
|
||||
function separatorClass(tone: 'down' | 'added' | 'removed'): string {
|
||||
if (tone === 'added') return 'bg-success/25'
|
||||
return 'bg-destructive/25'
|
||||
}
|
||||
|
||||
/**
|
||||
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge.
|
||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||
* Preview: https://reui.io/preview/base/empty-state-12
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function FailoverTimeline({
|
||||
events,
|
||||
history = [],
|
||||
}: {
|
||||
events: FailoverEvent[]
|
||||
history?: readonly FailoverHistoryItem[]
|
||||
}) {
|
||||
if (events.length === 0 && history.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Событий failover пока нет.
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={ShieldCheckIcon}
|
||||
title="Нет инцидентов"
|
||||
description="Нет Down и нет выходов из общего пула"
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline defaultValue={events.length} className="w-full">
|
||||
{events.map((event, index) => (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle>{event.title}</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineContent>{event.detail}</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
<div className="flex flex-col gap-4">
|
||||
{events.length > 0 ? (
|
||||
<Timeline defaultValue={0} className="gap-0">
|
||||
{events.map((event, index) => {
|
||||
const checkedIso = event.lastCheckAt
|
||||
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
|
||||
: null
|
||||
|
||||
return (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineSeparator className={separatorClass('down')} />
|
||||
<TimelineIndicator className={indicatorClass('down')} />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm">{event.address}</span>
|
||||
<HealthCheckBadge
|
||||
status={event.status as HealthBadgeStatus}
|
||||
lastError={event.lastFailureReason}
|
||||
lastCheckedAt={checkedIso}
|
||||
size="xs"
|
||||
/>
|
||||
</TimelineTitle>
|
||||
<TimelineDate>
|
||||
{event.consecutiveFailures > 0
|
||||
? failStreakLabel(event.consecutiveFailures)
|
||||
: null}
|
||||
{checkedIso
|
||||
? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
|
||||
: null}
|
||||
</TimelineDate>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="flex flex-col gap-2">
|
||||
<p className="text-foreground text-sm">
|
||||
{failoverEventCopy(event)}
|
||||
</p>
|
||||
{event.lastFailureReason ? (
|
||||
<code
|
||||
className={cn(
|
||||
'bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs',
|
||||
)}
|
||||
>
|
||||
{event.lastFailureReason}
|
||||
</code>
|
||||
) : null}
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
) : null}
|
||||
|
||||
{history.length > 0 ? (
|
||||
<Timeline defaultValue={0} className="gap-0">
|
||||
{history.map((item, index) => {
|
||||
const checkedIso = sqliteUtcToIso(item.created_at) ?? item.created_at
|
||||
const tone = item.action === 'added' ? 'added' : 'removed'
|
||||
|
||||
return (
|
||||
<TimelineItem key={item.id} step={index + 1}>
|
||||
<TimelineSeparator className={separatorClass(tone)} />
|
||||
<TimelineIndicator className={indicatorClass(tone)} />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm">{item.ip}</span>
|
||||
<HealthCheckBadge
|
||||
status={item.action === 'added' ? 'up' : 'down'}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{item.fqdn}
|
||||
</span>
|
||||
</TimelineTitle>
|
||||
<TimelineDate>
|
||||
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
|
||||
</TimelineDate>
|
||||
</TimelineHeader>
|
||||
<TimelineContent>
|
||||
<p className="text-foreground text-sm">{item.copy}</p>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,8 @@ type HealthStatus =
|
||||
|
||||
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
|
||||
if (status === 'healthy') return 'up'
|
||||
if (status === 'unhealthy' || status === 'disabled') return 'down'
|
||||
if (status === 'checking') return 'unknown'
|
||||
if (status === 'unhealthy') return 'down'
|
||||
if (status === 'disabled' || status === 'checking') return 'unknown'
|
||||
return status
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
NumberField,
|
||||
@@ -9,15 +10,8 @@ import {
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/components/reui/number-field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||
@@ -59,9 +53,14 @@ export interface LbAndHealthConfig extends HealthCheckConfig {
|
||||
const defaultLbModeOptions = [
|
||||
{ value: 'round_robin', label: 'Round Robin' },
|
||||
{ value: 'failover', label: 'Failover (приоритет)' },
|
||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||
{ value: 'weighted', label: 'Веса (подмена IP)' },
|
||||
]
|
||||
|
||||
export interface LbPoolMetaChange {
|
||||
weight?: number
|
||||
priority?: number
|
||||
}
|
||||
|
||||
function CompactNumberField({
|
||||
id,
|
||||
value,
|
||||
@@ -95,6 +94,89 @@ function CompactNumberField({
|
||||
)
|
||||
}
|
||||
|
||||
function PoolLbMetaFields({
|
||||
idPrefix,
|
||||
mode,
|
||||
ips,
|
||||
weights,
|
||||
priorities,
|
||||
onMetaChange,
|
||||
}: {
|
||||
idPrefix: string
|
||||
mode: Exclude<LbMode, 'round_robin'>
|
||||
ips: readonly string[]
|
||||
weights: Record<string, number>
|
||||
priorities: Record<string, number>
|
||||
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||
}) {
|
||||
const isWeighted = mode === 'weighted'
|
||||
const minPriority =
|
||||
ips.length === 0
|
||||
? 1
|
||||
: Math.min(...ips.map((ip) => priorities[ip] ?? 1))
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
|
||||
description={
|
||||
isWeighted
|
||||
? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
|
||||
: '1 — основной, больше — запасной'
|
||||
}
|
||||
compact
|
||||
stacked
|
||||
className="gap-3 px-0 py-3"
|
||||
contentClassName="min-w-0"
|
||||
>
|
||||
{ips.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Сначала добавьте IP выше</p>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{ips.map((ip) => {
|
||||
const isPrimary = (priorities[ip] ?? 1) === minPriority
|
||||
const fieldId = isWeighted
|
||||
? `${idPrefix}-weight-${ip}`
|
||||
: `${idPrefix}-priority-${ip}`
|
||||
return (
|
||||
<div key={ip} className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-sm">{ip}</span>
|
||||
{!isWeighted ? (
|
||||
<Badge
|
||||
variant={isPrimary ? 'success-light' : 'outline'}
|
||||
size="xs"
|
||||
radius="full"
|
||||
>
|
||||
{isPrimary ? 'Основной' : 'Запасной'}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Field className="w-28 gap-0">
|
||||
<FieldLabel htmlFor={fieldId} className="sr-only">
|
||||
{isWeighted ? `Вес ${ip}` : `Приоритет ${ip}`}
|
||||
</FieldLabel>
|
||||
<CompactNumberField
|
||||
id={fieldId}
|
||||
value={isWeighted ? (weights[ip] ?? 1) : (priorities[ip] ?? 1)}
|
||||
min={1}
|
||||
max={100}
|
||||
onValueChange={(next) =>
|
||||
onMetaChange?.(
|
||||
ip,
|
||||
isWeighted
|
||||
? { weight: next ?? 1 }
|
||||
: { priority: next ?? 1 },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
export function HealthCheckConfigFields({
|
||||
value,
|
||||
onChange,
|
||||
@@ -102,6 +184,10 @@ export function HealthCheckConfigFields({
|
||||
lbModeOptions = defaultLbModeOptions,
|
||||
idPrefix = 'health',
|
||||
showLbMode = true,
|
||||
ips = [],
|
||||
weights = {},
|
||||
priorities = {},
|
||||
onMetaChange,
|
||||
className,
|
||||
}: {
|
||||
value: LbAndHealthConfig
|
||||
@@ -110,6 +196,10 @@ export function HealthCheckConfigFields({
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
ips?: readonly string[]
|
||||
weights?: Record<string, number>
|
||||
priorities?: Record<string, number>
|
||||
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||
className?: string
|
||||
}) {
|
||||
function patch(next: Partial<LbAndHealthConfig>) {
|
||||
@@ -134,25 +224,28 @@ export function HealthCheckConfigFields({
|
||||
compact
|
||||
className={rowClass}
|
||||
>
|
||||
<Select
|
||||
<SelectField
|
||||
modal={false}
|
||||
value={value.lb_mode}
|
||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full">
|
||||
<SelectValue placeholder="Выберите режим" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lbModeOptions.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
triggerId={`${idPrefix}-lb-mode`}
|
||||
placeholder="Выберите режим"
|
||||
options={lbModeOptions}
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
|
||||
{showLbMode && value.lb_mode !== 'round_robin' ? (
|
||||
<PoolLbMetaFields
|
||||
idPrefix={idPrefix}
|
||||
mode={value.lb_mode}
|
||||
ips={ips}
|
||||
weights={weights}
|
||||
priorities={priorities}
|
||||
onMetaChange={onMetaChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Провайдер health-check"
|
||||
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
interface IncidentAlertProps {
|
||||
title: string
|
||||
description?: string
|
||||
onShow: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Баннер самого тяжёлого инцидента.
|
||||
* Preview: https://reui.io/docs/components/base/alert
|
||||
*/
|
||||
export function IncidentAlert({ title, description, onShow }: IncidentAlertProps) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon aria-hidden />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{description ? <AlertDescription>{description}</AlertDescription> : null}
|
||||
<AlertAction>
|
||||
<Button type="button" variant="outline" size="xs" onClick={onShow}>
|
||||
Показать
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { TriangleAlertIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
@@ -57,11 +59,23 @@ export function VpsTrackerIntegrationCard({
|
||||
|
||||
const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary'
|
||||
|
||||
const hasUnsaved = form.formState.isDirty
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
|
||||
>
|
||||
{hasUnsaved ? (
|
||||
<Alert variant="warning" role="status">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Есть несохранённые изменения</AlertTitle>
|
||||
<AlertDescription>
|
||||
URL, токен или синхронизация отличаются от последних сохранённых
|
||||
значений.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={syncBadgeVariant}>
|
||||
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useEffect, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { CommandPalette } from '@/components/layout/command-palette'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SearchMenu } from '@/components/layout/search-menu'
|
||||
import { SystemAttentionProvider } from '@/components/layout/system-attention-provider'
|
||||
import { bindLiveRefresh } from '@/lib/live-refresh'
|
||||
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
|
||||
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => bindLiveRefresh(queryClient), [queryClient])
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<SearchMenu hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
<SystemAttentionProvider>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</SystemAttentionProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
PaletteIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@cfdm/ui/components/command'
|
||||
import {
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
export const OPEN_COMMAND_PALETTE_EVENT = 'cfdm-open-command-palette'
|
||||
|
||||
const SECTION_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель',
|
||||
keywords: 'dashboard панель управление обзор',
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
to: '/domains',
|
||||
label: 'Домены',
|
||||
keywords: 'domains зоны cloudflare',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
keywords: 'services сервисы fqdn',
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
to: '/groups',
|
||||
label: 'Группы',
|
||||
keywords: 'groups группы доменов',
|
||||
icon: FolderTreeIcon,
|
||||
},
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
keywords: 'certificates ssl tls',
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/appearance',
|
||||
label: 'Настройки',
|
||||
keywords: 'settings настройки',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
type PaletteTarget = (typeof SECTION_ITEMS)[number]['to']
|
||||
|
||||
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const domainsQuery = useQuery({ ...domainsListQueryOptions(), enabled: open })
|
||||
const groupsQuery = useQuery({ ...groupsQueryOptions(), enabled: open })
|
||||
const servicesQuery = useQuery({ ...serviceGroupsQueryOptions(), enabled: open })
|
||||
|
||||
const services = useMemo(() => {
|
||||
const data = servicesQuery.data
|
||||
if (!data) return []
|
||||
return [...data.groups.flatMap((group) => group.services), ...data.ungrouped]
|
||||
}, [servicesQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.repeat) return
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
|
||||
event.preventDefault()
|
||||
setOpen((current) => !current)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function go(to: PaletteTarget) {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Разделы и действия приложения"
|
||||
className="sm:max-w-md"
|
||||
>
|
||||
<Command key={open ? 'open' : 'closed'}>
|
||||
<CommandInput autoFocus placeholder="Раздел, домен, сервис или действие…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||
<CommandGroup heading="Действия">
|
||||
<CommandItem value="Синхронизировать домены" onSelect={() => go('/domains')}>
|
||||
<GlobeIcon />
|
||||
<span>Синхронизировать домены</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Создать сервис" onSelect={() => go('/services')}>
|
||||
<ServerIcon />
|
||||
<span>Создать сервис</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Создать группу" onSelect={() => go('/groups')}>
|
||||
<FolderTreeIcon />
|
||||
<span>Создать группу</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Проверить сертификаты" onSelect={() => go('/certificates')}>
|
||||
<ShieldCheckIcon />
|
||||
<span>Проверить сертификаты</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
value="Переключить тему"
|
||||
onSelect={() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<PaletteIcon />
|
||||
<span>Переключить тему</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Разделы">
|
||||
{SECTION_ITEMS.map((item) => (
|
||||
<CommandItem
|
||||
key={item.to}
|
||||
value={`${item.label} ${item.keywords}`}
|
||||
onSelect={() => go(item.to)}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Домены">
|
||||
{(domainsQuery.data ?? []).slice(0, 8).map((domain) => (
|
||||
<CommandItem
|
||||
key={domain.id}
|
||||
value={`домен ${domain.zone_name}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/domains/$domainId',
|
||||
params: { domainId: String(domain.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<GlobeIcon />
|
||||
<span>{domain.zone_name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Сервисы">
|
||||
{services.slice(0, 8).map((service) => (
|
||||
<CommandItem
|
||||
key={service.id}
|
||||
value={`сервис ${service.name}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/services/$serviceId',
|
||||
params: { serviceId: String(service.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ServerIcon />
|
||||
<span>{service.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Группы">
|
||||
{(groupsQuery.data ?? []).slice(0, 8).map((group) => (
|
||||
<CommandItem
|
||||
key={group.id}
|
||||
value={`группа ${group.name} ${group.slug}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/groups/$groupId',
|
||||
params: { groupId: String(group.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FolderTreeIcon />
|
||||
<span>{group.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { isLiveRefresh, setLiveRefresh } from '@/lib/live-refresh'
|
||||
|
||||
function useLatestDataUpdatedAt() {
|
||||
const queryClient = useQueryClient()
|
||||
const [updatedAt, setUpdatedAt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const cache = queryClient.getQueryCache()
|
||||
let queued = false
|
||||
let active = true
|
||||
const read = () => {
|
||||
let latest = 0
|
||||
for (const query of cache.getAll()) {
|
||||
if (query.getObserversCount() === 0) continue
|
||||
if (query.state.dataUpdatedAt > latest) latest = query.state.dataUpdatedAt
|
||||
}
|
||||
setUpdatedAt((prev) => (prev === latest ? prev : latest))
|
||||
}
|
||||
// Cache notifies synchronously while another route renders (observerAdded).
|
||||
const schedule = () => {
|
||||
if (queued) return
|
||||
queued = true
|
||||
queueMicrotask(() => {
|
||||
queued = false
|
||||
if (active) read()
|
||||
})
|
||||
}
|
||||
read()
|
||||
const unsubscribe = cache.subscribe(schedule)
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
return updatedAt
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number) {
|
||||
if (updatedAt === 0) return '—'
|
||||
return formatRelative(new Date(updatedAt).toISOString())
|
||||
}
|
||||
|
||||
/** Компактный статус свежести слева от AppsMenu. */
|
||||
export function DataFreshness() {
|
||||
const queryClient = useQueryClient()
|
||||
const updatedAt = useLatestDataUpdatedAt()
|
||||
const [live, setLive] = useState(() => isLiveRefresh())
|
||||
const [label, setLabel] = useState('—')
|
||||
|
||||
useEffect(() => {
|
||||
const read = () => setLabel(formatUpdatedAt(updatedAt))
|
||||
read()
|
||||
if (updatedAt === 0) return
|
||||
const timer = window.setInterval(read, 15_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [updatedAt])
|
||||
|
||||
function handleCheckedChange(checked: boolean) {
|
||||
const next = Boolean(checked)
|
||||
setLiveRefresh(queryClient, next)
|
||||
setLive(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex">
|
||||
<span className="tabular-nums">
|
||||
Обновлено{' '}
|
||||
<time dateTime={updatedAt ? new Date(updatedAt).toISOString() : undefined}>
|
||||
{label}
|
||||
</time>
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{live ? 'Live' : 'Пауза'}</span>
|
||||
</p>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={live}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
aria-label="Автообновление"
|
||||
/>
|
||||
<span className="text-muted-foreground hidden text-xs sm:inline">авто</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
HeartPulseIcon,
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@cfdm/ui/components/dialog'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель управления',
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
to: '/domains',
|
||||
label: 'Домены',
|
||||
keywords: ['domains', 'зоны', 'cloudflare'],
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
to: '/groups',
|
||||
label: 'Группы доменов',
|
||||
keywords: ['groups', 'группы', 'канбан'],
|
||||
icon: FolderTreeIcon,
|
||||
},
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
keywords: ['services', 'сервисы', 'fqdn'],
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
keywords: ['certificates', 'ssl', 'tls'],
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/health',
|
||||
label: 'Health-check',
|
||||
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
|
||||
icon: HeartPulseIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/integrations',
|
||||
label: 'Настройки',
|
||||
keywords: ['settings', 'интеграции', 'app switcher'],
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Command-K search — hotkey dialog (no header chrome trigger). Etalon: EvoBGP CommandPalette hotkeyOnly. */
|
||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return NAV_ITEMS
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.keywords.some((k) => k.includes(q)),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
function goTo(to: string) {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotkeyOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-4 opacity-60 select-none"
|
||||
/>
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Перейти к разделу…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) {
|
||||
e.preventDefault()
|
||||
goTo(filtered[0].to)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<Item
|
||||
key={item.to}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="cursor-pointer border-0"
|
||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||
import { MenuIcon } from 'lucide-react'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -9,9 +10,12 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { DataFreshness } from '@/components/layout/data-freshness'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
import { OPEN_COMMAND_PALETTE_EVENT } from '@/components/layout/command-palette'
|
||||
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
@@ -32,7 +36,7 @@ function useDynamicBreadcrumbLabels() {
|
||||
}, [matches])
|
||||
}
|
||||
|
||||
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||
/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
@@ -44,6 +48,15 @@ export function SiteHeader() {
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Меню и поиск"
|
||||
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
||||
>
|
||||
<MenuIcon aria-hidden />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
@@ -71,6 +84,7 @@ export function SiteHeader() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<DataFreshness />
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
SystemAttentionContext,
|
||||
useSystemAttentionData,
|
||||
} from '@/hooks/use-system-attention'
|
||||
|
||||
export function SystemAttentionProvider({ children }: { children: ReactNode }) {
|
||||
const attention = useSystemAttentionData()
|
||||
return (
|
||||
<SystemAttentionContext.Provider value={attention}>
|
||||
{children}
|
||||
</SystemAttentionContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Activity,
|
||||
HeartPulse,
|
||||
@@ -8,18 +8,14 @@ import {
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { useSystemAttention } from '@/hooks/use-system-attention'
|
||||
import type { SystemIncident } from '@/lib/system-attention'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
|
||||
import {
|
||||
certSummaryQueryOptions,
|
||||
domainsListQueryOptions,
|
||||
notificationLogQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
const VISIBLE_INCIDENTS = 8
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
@@ -32,12 +28,6 @@ type MonitorMetric = {
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
type ReadyResponse = {
|
||||
status: string
|
||||
database?: boolean
|
||||
cloudflare?: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
@@ -81,110 +71,95 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
)
|
||||
}
|
||||
|
||||
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
||||
if (!summary) return 0
|
||||
return summary
|
||||
.filter(([status]) => statuses.includes(status))
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
function IncidentLink({ incident }: { incident: SystemIncident }) {
|
||||
const className =
|
||||
'hover:bg-muted flex items-center gap-2 rounded-md px-1.5 py-1 text-xs'
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
incident.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{incident.title}</span>
|
||||
<span className="text-muted-foreground shrink-0">{incident.detail}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (incident.href.to === '/certificates') {
|
||||
return (
|
||||
<Link to="/certificates" className={className}>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: incident.href.serviceId }}
|
||||
className={className}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell pattern, CFDM API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const readyQ = useQuery({
|
||||
queryKey: ['ready'],
|
||||
queryFn: async (): Promise<ReadyResponse> => {
|
||||
const res = await fetch('/ready')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<ReadyResponse>
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 })
|
||||
const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 })
|
||||
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 })
|
||||
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 })
|
||||
|
||||
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
|
||||
const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError
|
||||
|
||||
let healthDown = 0
|
||||
let healthDegraded = 0
|
||||
for (const group of servicesQ.data?.groups ?? []) {
|
||||
for (const service of group.services) {
|
||||
if (service.health_status === 'down') healthDown += 1
|
||||
if (service.health_status === 'degraded') healthDegraded += 1
|
||||
}
|
||||
}
|
||||
for (const service of servicesQ.data?.ungrouped ?? []) {
|
||||
if (service.health_status === 'down') healthDown += 1
|
||||
if (service.health_status === 'degraded') healthDegraded += 1
|
||||
}
|
||||
const healthAlert = healthDown > 0
|
||||
const healthWarn = healthDegraded > 0
|
||||
|
||||
const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error'])
|
||||
const failedJobs = (notifyQ.data ?? []).filter((n) =>
|
||||
/fail|error/i.test(`${n.kind} ${n.title} ${n.message}`),
|
||||
).length
|
||||
const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length
|
||||
const attentionCount = healthDown + healthDegraded
|
||||
const attention = useSystemAttention()
|
||||
const visibleIncidents = attention.incidents.slice(0, VISIBLE_INCIDENTS)
|
||||
const hiddenIncidents = attention.incidents.length - visibleIncidents.length
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ready',
|
||||
label: 'Ready',
|
||||
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
|
||||
value: attention.readyOk ? 'OK' : attention.readyDegraded ? '!' : '—',
|
||||
unit: '',
|
||||
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
|
||||
percent: attention.readyOk ? 100 : attention.readyDegraded ? 40 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
tone: readyOk ? 'success' : 'destructive',
|
||||
alert: !readyOk,
|
||||
tone: attention.readyOk ? 'success' : 'destructive',
|
||||
alert: !attention.readyOk,
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
label: 'Health-check',
|
||||
value: String(healthDown + healthDegraded),
|
||||
value: String(attention.servicesDown + attention.servicesDegraded),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, (healthDown + healthDegraded) * 20),
|
||||
percent: Math.min(100, (attention.servicesDown + attention.servicesDegraded) * 20),
|
||||
icon: <Server aria-hidden />,
|
||||
tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success',
|
||||
alert: healthAlert || healthWarn,
|
||||
tone: attention.servicesDown > 0 ? 'destructive' : attention.servicesDegraded > 0 ? 'warning' : 'success',
|
||||
alert: attention.servicesDown > 0 || attention.servicesDegraded > 0,
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
label: 'Сертификаты',
|
||||
value: String(certWarnings),
|
||||
value: String(attention.certWarnings),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, certWarnings * 20),
|
||||
percent: Math.min(100, attention.certWarnings * 20),
|
||||
icon: <ShieldCheck aria-hidden />,
|
||||
tone: certWarnings > 0 ? 'warning' : 'success',
|
||||
alert: certWarnings > 0,
|
||||
tone: attention.certWarnings > 0 ? 'warning' : 'success',
|
||||
alert: attention.certWarnings > 0,
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Ошибки',
|
||||
value: String(failedJobs),
|
||||
value: String(attention.failedJobs),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedJobs * 15),
|
||||
percent: Math.min(100, attention.failedJobs * 15),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: failedJobs > 0,
|
||||
tone: attention.failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: attention.failedJobs > 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
certWarnings,
|
||||
failedJobs,
|
||||
healthAlert,
|
||||
healthDegraded,
|
||||
healthDown,
|
||||
healthWarn,
|
||||
readyDegraded,
|
||||
readyOk,
|
||||
],
|
||||
[attention],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert)
|
||||
const spiking = metrics.some((metric) => metric.alert)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -192,7 +167,11 @@ export function SystemMonitorPopover() {
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
aria-label={
|
||||
spiking
|
||||
? `Состояние системы: внимание, ошибок ${attention.servicesDown}, предупреждений ${attention.servicesDegraded + attention.certWarnings}`
|
||||
: 'Состояние системы: норма'
|
||||
}
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
@@ -212,14 +191,15 @@ export function SystemMonitorPopover() {
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden items-center gap-1 text-[11px] tabular-nums md:inline-flex">
|
||||
<i className="bg-destructive inline-block size-1.5 rounded-full" aria-hidden />
|
||||
{attention.servicesDown}
|
||||
<i className="bg-warning inline-block size-1.5 rounded-full" aria-hidden />
|
||||
{attention.servicesDegraded + attention.certWarnings}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
@@ -242,12 +222,29 @@ export function SystemMonitorPopover() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border flex flex-col gap-1 border-t px-2 py-2">
|
||||
<span className="text-muted-foreground px-1.5 text-xs font-medium">Инциденты</span>
|
||||
{visibleIncidents.length === 0 ? (
|
||||
<p className="text-muted-foreground px-1.5 py-1 text-xs">Нет инцидентов</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{visibleIncidents.map((incident) => (
|
||||
<IncidentLink key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hiddenIncidents > 0 ? (
|
||||
<p className="text-muted-foreground px-1.5 text-xs">Ещё {hiddenIncidents}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Без группы:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{ungroupedCount}</span>
|
||||
<span className="text-foreground font-medium tabular-nums">{attention.ungroupedCount}</span>
|
||||
{' · '}
|
||||
Внимание:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{attentionCount}</span>
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{attention.servicesDown + attention.servicesDegraded}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -41,6 +41,14 @@ import {
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
import {
|
||||
readContrast,
|
||||
readDensity,
|
||||
writeContrast,
|
||||
writeDensity,
|
||||
type ContrastMode,
|
||||
type DensityMode,
|
||||
} from '@/lib/appearance-mode'
|
||||
|
||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
@@ -107,6 +115,98 @@ function ThemeSegmentedToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedChoice<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly { value: T; label: string }[]
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isActive = value === option.value
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
size="xs"
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceToggles() {
|
||||
const [contrast, setContrast] = useState<ContrastMode>('standard')
|
||||
const [density, setDensity] = useState<DensityMode>('normal')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setContrast(readContrast())
|
||||
setDensity(readDensity())
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<span className="text-sm">Контраст</span>
|
||||
<div className="ml-auto">
|
||||
<SegmentedChoice
|
||||
label="Контраст"
|
||||
value={contrast}
|
||||
options={[
|
||||
{ value: 'standard', label: 'Обычный' },
|
||||
{ value: 'high', label: 'Высокий' },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setContrast(next)
|
||||
writeContrast(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<span className="text-sm">Плотность</span>
|
||||
<div className="ml-auto">
|
||||
<SegmentedChoice
|
||||
label="Плотность"
|
||||
value={density}
|
||||
options={[
|
||||
{ value: 'normal', label: 'Обычная' },
|
||||
{ value: 'compact', label: 'Компакт' },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setDensity(next)
|
||||
writeDensity(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string, email: string): string {
|
||||
const base = name.trim() || email.trim()
|
||||
if (!base) return '?'
|
||||
@@ -202,6 +302,7 @@ export function NavUser() {
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<AppearanceToggles />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -16,7 +16,9 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { CircleAlertIcon, type LucideIcon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
@@ -9,12 +14,14 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Sibling Frame columns for dashboard attention queue.
|
||||
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||
* Each column is its own Frame; children must not wrap another Frame.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/dashboard-1
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
export interface AttentionQueueColumn {
|
||||
id: string
|
||||
@@ -26,6 +33,9 @@ export interface AttentionQueueColumn {
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
emptyAction?: ReactNode
|
||||
isError?: boolean
|
||||
errorMessage?: string
|
||||
onRetry?: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
@@ -40,13 +50,14 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
|
||||
'flex min-w-0 flex-col items-stretch gap-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const Icon = column.icon
|
||||
const isEmpty = column.count === 0
|
||||
const isError = column.isError === true
|
||||
const isEmpty = !isError && column.count === 0
|
||||
return (
|
||||
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader>
|
||||
@@ -71,8 +82,30 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
) : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="min-w-0">
|
||||
{isEmpty ? (
|
||||
<FramePanel className={cn('min-w-0', !isEmpty && !isError && 'p-0')}>
|
||||
{isError ? (
|
||||
<div className="flex min-h-28 items-center py-4">
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{column.errorMessage ?? 'Не удалось загрузить данные'}
|
||||
</span>
|
||||
{column.onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={column.onRetry}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex min-h-28 items-center justify-center py-4">
|
||||
<EmptyState
|
||||
icon={Icon}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useEffect, useRef } from 'react'
|
||||
import { ActivityIcon } from 'lucide-react'
|
||||
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -229,3 +230,31 @@ export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок «Здоровье нод» в форме area-chart (chart-17): заголовок и легенда.
|
||||
* Почасового ряда Healthy/Unhealthy API не отдаёт — пустое состояние, без выдуманных точек.
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function NodeHealthChart() {
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Здоровье нод</FrameTitle>
|
||||
<FrameDescription>
|
||||
Healthy / Unhealthy по проверкам health-check · последние 24 часа
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex min-h-52 flex-col">
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет ряда за 24 часа"
|
||||
description="Сводка нод есть. Почасовой ряд Healthy и Unhealthy API не отдаёт, поэтому точки не подставляются."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
import type { Certificate, DomainListItem, ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
/**
|
||||
* Compact queue tables for the dashboard. Parent Frame owns the shell —
|
||||
* this grid does not render another Frame.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Docs: https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
|
||||
function formatWhen(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
return formatRelative(sqliteUtcToIso(value) ?? value)
|
||||
}
|
||||
|
||||
function nodeLabel(service: ServiceView) {
|
||||
const health = service.ip_health
|
||||
const total = Math.max(service.ips.length, health.length)
|
||||
if (total === 0) return '—'
|
||||
if (health.length === 0) return String(total)
|
||||
const healthy = health.filter((row) => row.status === 'up').length
|
||||
return `${healthy}/${total}`
|
||||
}
|
||||
|
||||
function latestCheck(service: ServiceView) {
|
||||
let checkedAt: string | null = null
|
||||
for (const row of service.ip_health) {
|
||||
if (!row.last_checked_at) continue
|
||||
if (!checkedAt || row.last_checked_at > checkedAt) checkedAt = row.last_checked_at
|
||||
}
|
||||
return checkedAt
|
||||
}
|
||||
|
||||
function QueueGrid<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
}: {
|
||||
data: T[]
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
getRowId: (row: T) => string
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
headerBorder: true,
|
||||
rowBorder: true,
|
||||
width: 'auto',
|
||||
}}
|
||||
>
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface HealthProblemGridProps {
|
||||
services: ServiceView[]
|
||||
}
|
||||
|
||||
export function HealthProblemGrid({ services }: HealthProblemGridProps) {
|
||||
const columns = useMemo<ColumnDef<ServiceView, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
size: 140,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервис" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ serviceId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'health_status',
|
||||
size: 96,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge status={row.original.health_status} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
accessorKey: 'health_latency_ms',
|
||||
size: 88,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Задержка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{row.original.health_latency_ms != null
|
||||
? `${row.original.health_latency_ms} мс`
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
accessorFn: (row) => Math.max(row.ips.length, row.ip_health.length),
|
||||
size: 72,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Ноды" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{nodeLabel(row.original)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'checked',
|
||||
accessorFn: (row) => latestCheck(row) ?? '',
|
||||
size: 104,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Когда" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(latestCheck(row.original))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid
|
||||
data={services}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExpiringCertGridProps {
|
||||
certs: Certificate[]
|
||||
}
|
||||
|
||||
export function ExpiringCertGrid({ certs }: ExpiringCertGridProps) {
|
||||
const columns = useMemo<ColumnDef<Certificate, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Хост" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
{row.original.hostname}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
accessorFn: (row) => row.service_name ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервис" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate">
|
||||
{row.original.service_name?.trim() || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expires',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid data={certs} columns={columns} getRowId={(row) => String(row.id)} />
|
||||
)
|
||||
}
|
||||
|
||||
interface UngroupedDomainGridProps {
|
||||
domains: DomainListItem[]
|
||||
}
|
||||
|
||||
export function UngroupedDomainGrid({ domains }: UngroupedDomainGridProps) {
|
||||
const columns = useMemo<ColumnDef<DomainListItem, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Домен" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.service_count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'synced',
|
||||
accessorKey: 'last_synced_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Синхронизация" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(row.original.last_synced_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid
|
||||
data={domains}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||
export { ServiceFailoverPanel } from './service-failover-panel'
|
||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||
export { CertStatusChart, GroupDomainsChart, NodeHealthChart } from './dashboard-analytics'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
export {
|
||||
KpiStatGrid,
|
||||
@@ -16,6 +17,11 @@ export {
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
|
||||
export {
|
||||
ExpiringCertGrid,
|
||||
HealthProblemGrid,
|
||||
UngroupedDomainGrid,
|
||||
} from './dashboard-queue-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
@@ -40,14 +39,18 @@ export type OpsKpiCard = KpiStatCardData
|
||||
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||
export type KpiStatCard = KpiStatCardData
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground'
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: 'text-foreground',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
const HINT_VARIANT: Record<KpiStatVariant, 'outline' | 'warning-light' | 'destructive-light'> = {
|
||||
default: 'outline',
|
||||
warning: 'warning-light',
|
||||
destructive: 'destructive-light',
|
||||
}
|
||||
|
||||
/** stats-12 tile: https://reui.io/preview/base/stats-12 */
|
||||
const STAT_ICON_CLASS =
|
||||
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5'
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
@@ -67,7 +70,11 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||
<Badge
|
||||
variant={HINT_VARIANT[item.variant ?? 'default']}
|
||||
size="sm"
|
||||
className="max-w-full truncate"
|
||||
>
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
@@ -78,43 +85,30 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||
<div className="relative z-10 flex h-full min-w-0 flex-col items-start gap-6">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
<Item
|
||||
className={cn(STAT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="text-foreground min-w-0 text-2xl leading-none font-bold break-all tabular-nums">
|
||||
{item.value}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||
) : null}
|
||||
<div className="text-muted-foreground min-w-0 text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,24 +190,28 @@ export function KpiStatCard({
|
||||
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||
}
|
||||
|
||||
function statGridClass(count: number) {
|
||||
if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
|
||||
if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
|
||||
return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<FramePanel key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||
</div>
|
||||
<div className={cn('@container grid w-full', statGridClass(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<Frame key={index}>
|
||||
<FramePanel className="flex flex-col items-start gap-6">
|
||||
<Skeleton className="size-10.5 rounded-lg" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-7 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-28 rounded-full" />
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,44 +230,10 @@ interface KpiStatGridProps {
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||
* KPI grid from stats-12: one Frame per metric, icon tile, value, label, badge.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/blocks/application/stats/stats-12
|
||||
*/
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
@@ -301,26 +265,18 @@ export function KpiStatGrid({
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid', statGridClass(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
<KpiStatCardTile
|
||||
key={kpiStatItemKey(item, index)}
|
||||
item={item}
|
||||
embedded={embedded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ interface OpsDashboardProps {
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
queueTitle?: string
|
||||
queueDescription?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
@@ -48,7 +47,6 @@ export function OpsDashboard({
|
||||
charts,
|
||||
queue,
|
||||
queueTitle = 'Требуют внимания',
|
||||
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
|
||||
isLoading = false,
|
||||
}: OpsDashboardProps) {
|
||||
if (isLoading) {
|
||||
@@ -63,22 +61,11 @@ export function OpsDashboard({
|
||||
|
||||
{afterKpi}
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">
|
||||
{queueDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||
{queue}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
@@ -20,6 +18,7 @@ export interface QuickActionItem {
|
||||
search?: Record<string, unknown>
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
badgeVariant?: 'info-light' | 'warning-light'
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
@@ -29,39 +28,10 @@ interface QuickActionGridProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
Перейти
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Quick links from card-12, with the artboard title, description and «Перейти».
|
||||
* Preview: https://reui.io/preview/base/card-12
|
||||
* Docs: https://reui.io/blocks/application/card/card-12
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
@@ -72,28 +42,37 @@ export function QuickActionGrid({
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||
{(title || description) && (
|
||||
<Frame className={cn('w-full', className)}>
|
||||
{title || description ? (
|
||||
<FrameHeader>
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
)}
|
||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||
) : null}
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{actions.map((action) => (
|
||||
<FramePanel
|
||||
<Link
|
||||
key={action.id}
|
||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
<FramePanel className="flex h-full flex-col gap-3 p-3.5">
|
||||
<span className={cn('[&_svg]:size-5', action.iconClassName)} aria-hidden="true">
|
||||
{action.icon}
|
||||
</span>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
|
||||
{action.title}
|
||||
</h3>
|
||||
<Badge variant={action.badgeVariant ?? 'info-light'} size="sm">
|
||||
Перейти
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{action.description}</p>
|
||||
</FramePanel>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
FilterIcon,
|
||||
FunnelXIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
@@ -67,7 +72,27 @@ export interface ResourcePageProps<T extends object> {
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
primaryAction?: ReactNode
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
emptyState?: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
icon?: LucideIcon
|
||||
}
|
||||
/** Пустой результат фильтров или вкладки. Preview: https://reui.io/preview/base/empty-state-12 */
|
||||
filteredEmptyState?: {
|
||||
title: string
|
||||
description?: string
|
||||
resetLabel?: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
onFilteredEmptyReset?: () => void
|
||||
/** Ошибка загрузки через EmptyState вместо общего Alert. */
|
||||
errorState?: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
icon?: LucideIcon
|
||||
}
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
selectionToolbar?: (ctx: {
|
||||
@@ -119,6 +144,9 @@ export function ResourcePage<T extends object>({
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
filteredEmptyState,
|
||||
onFilteredEmptyReset,
|
||||
errorState,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
selectionToolbar,
|
||||
@@ -224,6 +252,25 @@ export function ResourcePage<T extends object>({
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (errorState) {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
|
||||
<EmptyState
|
||||
icon={errorState.icon ?? CircleAlertIcon}
|
||||
title={errorState.title}
|
||||
description={
|
||||
errorState.description ??
|
||||
error?.message ??
|
||||
'Не удалось загрузить данные'
|
||||
}
|
||||
action={errorState.action}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
@@ -262,6 +309,7 @@ export function ResourcePage<T extends object>({
|
||||
) : null}
|
||||
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
|
||||
<EmptyState
|
||||
icon={emptyState.icon}
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={emptyState.action}
|
||||
@@ -271,7 +319,30 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
const emptyMessage = filteredEmptyState ? (
|
||||
<EmptyState
|
||||
className="text-foreground"
|
||||
icon={filteredEmptyState.icon}
|
||||
title={filteredEmptyState.title}
|
||||
description={filteredEmptyState.description}
|
||||
action={
|
||||
filteredEmptyState.resetLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleClear()
|
||||
onFilteredEmptyReset?.()
|
||||
}}
|
||||
>
|
||||
{filteredEmptyState.resetLabel}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
'Нет записей по выбранным фильтрам.'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -16,10 +16,13 @@ import { parseFqdn } from '@/lib/parse-fqdn'
|
||||
import {
|
||||
addAddressNode,
|
||||
addCommonFqdn,
|
||||
addExtraFqdn,
|
||||
addressHasFqdn,
|
||||
removeAddressNode,
|
||||
removeCommonFqdn,
|
||||
removeExtraFqdn,
|
||||
updateCommonFqdn,
|
||||
updateExtraFqdn,
|
||||
type AddressBlockState,
|
||||
} from '@/lib/service-address'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
@@ -67,7 +70,7 @@ function ZoneAddon({
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменом.
|
||||
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменами.
|
||||
* Preview: https://reui.io/preview/base/settings-3
|
||||
* Preview: https://reui.io/preview/base/list-9
|
||||
* Preview: https://reui.io/preview/base/form-7
|
||||
@@ -88,6 +91,8 @@ export function ServiceAddressBlock({
|
||||
const [ipInvalid, setIpInvalid] = useState(false)
|
||||
const [pendingFqdn, setPendingFqdn] = useState('')
|
||||
const [fqdnInvalid, setFqdnInvalid] = useState(false)
|
||||
const [pendingExtraByIp, setPendingExtraByIp] = useState<Record<string, string>>({})
|
||||
const [extraInvalidByIp, setExtraInvalidByIp] = useState<Record<string, boolean>>({})
|
||||
|
||||
const pool = value.nodes.map((node) => node.ip)
|
||||
const pendingIpTrimmed = pendingIp.trim()
|
||||
@@ -141,13 +146,26 @@ export function ServiceAddressBlock({
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeFqdn(ip: string, extraFqdn: string) {
|
||||
onChange({
|
||||
...value,
|
||||
nodes: value.nodes.map((node) =>
|
||||
node.ip === ip ? { ...node, extraFqdn } : node,
|
||||
),
|
||||
})
|
||||
function tryAddExtra(ip: string, raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
|
||||
return
|
||||
}
|
||||
if (addressHasFqdn(value, trimmed)) {
|
||||
setExtraInvalidByIp((current) => ({ ...current, [ip]: true }))
|
||||
return
|
||||
}
|
||||
onChange(addExtraFqdn(value, ip, trimmed))
|
||||
setPendingExtraByIp((current) => ({ ...current, [ip]: '' }))
|
||||
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
|
||||
}
|
||||
|
||||
function handleExtraKeyDown(ip: string, event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
tryAddExtra(ip, pendingExtraByIp[ip] ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -156,7 +174,7 @@ export function ServiceAddressBlock({
|
||||
<FrameHeader className="px-0 pt-0">
|
||||
<FrameTitle>Адреса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Общие FQDN — на весь пул · у IP свой доп. домен
|
||||
Общие FQDN — на весь пул · у IP свои доп. домены
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<Field>
|
||||
@@ -224,7 +242,7 @@ export function ServiceAddressBlock({
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Добавьте IP пула"
|
||||
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
|
||||
description="IPv4 сервиса. Для каждого адреса можно указать несколько доп. FQDN, в том числе wildcard."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
@@ -265,27 +283,98 @@ export function ServiceAddressBlock({
|
||||
</div>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel
|
||||
htmlFor={`service-ip-extra-${node.ip}`}
|
||||
htmlFor={`service-ip-extra-add-${node.ip}`}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Доп. FQDN
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`service-ip-extra-${node.ip}`}
|
||||
className="font-mono"
|
||||
value={node.extraFqdn}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `необязательно · spb.${zoneHints[0]}`
|
||||
: 'необязательно · spb.example.com'
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleNodeFqdn(node.ip, event.target.value)
|
||||
}
|
||||
/>
|
||||
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
|
||||
</InputGroup>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{node.extraFqdns.map((fqdn, index) => (
|
||||
<InputGroup key={`extra-fqdn-${node.ip}-${index}`}>
|
||||
<InputGroupInput
|
||||
id={`service-ip-extra-${node.ip}-${index}`}
|
||||
className="font-mono"
|
||||
value={fqdn}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `*.mdns.${zoneHints[0]}`
|
||||
: '*.mdns.example.com'
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
updateExtraFqdn(
|
||||
value,
|
||||
node.ip,
|
||||
index,
|
||||
event.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={fqdn}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Удалить ${fqdn || 'FQDN'}`}
|
||||
onClick={() =>
|
||||
onChange(removeExtraFqdn(value, node.ip, index))
|
||||
}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
))}
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`service-ip-extra-add-${node.ip}`}
|
||||
className="font-mono"
|
||||
value={pendingExtraByIp[node.ip] ?? ''}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `необязательно · *.mdns.${zoneHints[0]}`
|
||||
: 'необязательно · *.mdns.example.com'
|
||||
}
|
||||
aria-invalid={
|
||||
extraInvalidByIp[node.ip] &&
|
||||
(pendingExtraByIp[node.ip] ?? '').trim().length > 0
|
||||
? true
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => {
|
||||
setPendingExtraByIp((current) => ({
|
||||
...current,
|
||||
[node.ip]: event.target.value,
|
||||
}))
|
||||
setExtraInvalidByIp((current) => ({
|
||||
...current,
|
||||
[node.ip]: false,
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => handleExtraKeyDown(node.ip, event)}
|
||||
onBlur={() =>
|
||||
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
|
||||
}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={pendingExtraByIp[node.ip] ?? ''}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
|
||||
}
|
||||
>
|
||||
Добавить
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</Field>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { UnplugIcon } from 'lucide-react'
|
||||
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import {
|
||||
mergeFailoverHistory,
|
||||
toFailoverEvents,
|
||||
type FailoverBindingPool,
|
||||
type FailoverHealthInput,
|
||||
} from '@/lib/failover-events'
|
||||
import {
|
||||
latestHealthByIp,
|
||||
resolveIpDisplayHealth,
|
||||
type HealthLogProbe,
|
||||
type HealthLogStatus,
|
||||
} from '@/lib/health-log'
|
||||
import type { FailoverLogEntry, ServiceView } from '@/lib/schemas'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
|
||||
type LbMode = ServiceView['lb_mode']
|
||||
|
||||
const PANEL_COPY: Record<LbMode, { title: string; description: string }> = {
|
||||
failover: {
|
||||
title: 'Failover (приоритет)',
|
||||
description: 'Down снимается с общего FQDN. История: кто вышел из пула и кто вернулся',
|
||||
},
|
||||
weighted: {
|
||||
title: 'Веса (подмена IP)',
|
||||
description: 'На общем FQDN один IP по весам. Down выводится из цикла',
|
||||
},
|
||||
round_robin: {
|
||||
title: 'Round Robin',
|
||||
description: 'На общем FQDN все живые A. Down снимается с пула',
|
||||
},
|
||||
}
|
||||
|
||||
function failoverCountLabel(count: number): string {
|
||||
const mod10 = count % 10
|
||||
const mod100 = count % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} адрес Down`
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||||
return `${count} адреса Down`
|
||||
}
|
||||
return `${count} адресов Down`
|
||||
}
|
||||
|
||||
function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
|
||||
const removedCount = events.filter((event) => event.kind === 'removed').length
|
||||
if (removedCount > 0) return 'Сняты с общего FQDN'
|
||||
if (events.some((event) => event.fqdns.length > 0)) {
|
||||
return 'Остались в A-записях общего FQDN как last-resort'
|
||||
}
|
||||
return 'Down: персональные FQDN не меняются'
|
||||
}
|
||||
|
||||
/**
|
||||
* Балансировка — текущие Down + кто вышел из общего пула.
|
||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||
* Preview: https://reui.io/preview/base/empty-state-12
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
* Docs: https://reui.io/docs/components/base/alert
|
||||
*/
|
||||
export function ServiceFailoverPanel({
|
||||
lbMode = 'round_robin',
|
||||
hasPool = true,
|
||||
ipHealth,
|
||||
bindings,
|
||||
history,
|
||||
probes = [],
|
||||
}: {
|
||||
lbMode?: LbMode
|
||||
hasPool?: boolean
|
||||
ipHealth: readonly FailoverHealthInput[]
|
||||
bindings: readonly FailoverBindingPool[]
|
||||
history: readonly FailoverLogEntry[]
|
||||
probes?: readonly HealthLogProbe[]
|
||||
}) {
|
||||
const copy = hasPool
|
||||
? (PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin)
|
||||
: {
|
||||
title: 'без резервирования',
|
||||
description: 'Один origin IP — балансировка не применяется',
|
||||
}
|
||||
const liveByIp = latestHealthByIp(probes)
|
||||
const overlayHealth = ipHealth.map((row) => {
|
||||
const live = liveByIp.get(row.ip)
|
||||
return {
|
||||
...row,
|
||||
status: resolveIpDisplayHealth(
|
||||
row.status as HealthLogStatus,
|
||||
live?.status,
|
||||
),
|
||||
last_error:
|
||||
live && live.status !== 'unknown' ? live.last_error : row.last_error,
|
||||
last_checked_at:
|
||||
live && live.status !== 'unknown'
|
||||
? live.last_checked_at
|
||||
: row.last_checked_at,
|
||||
}
|
||||
})
|
||||
const events = toFailoverEvents(overlayHealth, bindings)
|
||||
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<FrameHeader className="gap-1 px-0 py-0">
|
||||
<FrameTitle className="flex flex-wrap items-center gap-2">
|
||||
{copy.title}
|
||||
{events.length > 0 ? (
|
||||
<Badge variant="destructive-light" size="xs" radius="full">
|
||||
{events.length}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success-light" size="xs" radius="full">
|
||||
OK
|
||||
</Badge>
|
||||
)}
|
||||
</FrameTitle>
|
||||
<FrameDescription>{copy.description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<UnplugIcon aria-hidden="true" />
|
||||
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
|
||||
<AlertDescription>{alertDescription(events)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FailoverTimeline events={events} history={mergedHistory} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +1,67 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
|
||||
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
|
||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
|
||||
export interface SettingsTabConfig {
|
||||
id: string
|
||||
to: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
const SETTINGS_TABS = [
|
||||
{ to: '/settings/appearance', label: 'Внешний вид' },
|
||||
{ to: '/settings/health', label: 'Health-check' },
|
||||
{ to: '/settings/integrations', label: 'Интеграции' },
|
||||
] as const
|
||||
|
||||
type SettingsTab = (typeof SETTINGS_TABS)[number]['to']
|
||||
|
||||
function activeTab(pathname: string): SettingsTab {
|
||||
const match = SETTINGS_TABS.find(
|
||||
(tab) => pathname === tab.to || pathname.startsWith(`${tab.to}/`),
|
||||
)
|
||||
return match?.to ?? '/settings/appearance'
|
||||
}
|
||||
|
||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||
{
|
||||
id: 'appearance',
|
||||
to: '/settings/appearance',
|
||||
label: 'Внешний вид',
|
||||
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
to: '/settings/health',
|
||||
label: 'Health-check',
|
||||
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
to: '/settings/integrations',
|
||||
label: 'Интеграции',
|
||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
title?: string
|
||||
description?: string
|
||||
tabs?: SettingsTabConfig[]
|
||||
}
|
||||
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description = 'Внешний вид, health-check и интеграции',
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
/**
|
||||
* Настройки CfSettings: вкладки внутри страницы, не боковой rail.
|
||||
* Строки секций — settings-3 SettingField / SettingRow.
|
||||
* Preview: https://reui.io/preview/base/settings-3
|
||||
* Preview: https://reui.io/preview/base/settings-16
|
||||
* Docs: https://reui.io/blocks/application/settings
|
||||
*/
|
||||
export function SettingsShell() {
|
||||
const navigate = useNavigate()
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname })
|
||||
const value = activeTab(pathname)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||
<PageHeader title={title} description={description} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-5',
|
||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||
)}
|
||||
>
|
||||
{tabs.length > 1 ? (
|
||||
<nav
|
||||
aria-label="Разделы настроек"
|
||||
className={cn(
|
||||
'flex gap-1',
|
||||
isMobile
|
||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||
: 'w-44 shrink-0 flex-col',
|
||||
)}
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Интерфейс, health-check и интеграции"
|
||||
/>
|
||||
<Tabs
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
const tab = SETTINGS_TABS.find((item) => item.to === next)
|
||||
if (!tab) return
|
||||
void navigate({ to: tab.to })
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" className="w-full justify-start gap-5">
|
||||
{SETTINGS_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.to}
|
||||
value={tab.to}
|
||||
nativeButton={false}
|
||||
render={<Link to={tab.to} />}
|
||||
className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname.startsWith(tab.to)
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={tab.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
isMobile && 'shrink-0',
|
||||
!isMobile && 'w-full',
|
||||
isActive
|
||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export type { SettingsTab as SettingsTabConfig }
|
||||
@@ -45,6 +45,28 @@ describe('toAlignedSeries', () => {
|
||||
expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
|
||||
})
|
||||
|
||||
it('keeps live latency when another IP in the same bucket is down', () => {
|
||||
const { points } = toAlignedSeries([
|
||||
probe({
|
||||
id: 1,
|
||||
latency_ms: 18,
|
||||
checked_at: '2026-01-01T00:00:10.000Z',
|
||||
}),
|
||||
probe({
|
||||
id: 2,
|
||||
status: 'down',
|
||||
ok: false,
|
||||
latency_ms: null,
|
||||
checked_at: '2026-01-01T00:00:12.000Z',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]?.local).toBe(18)
|
||||
expect(points[0]?.localOk).toBe(true)
|
||||
expect(points[0]?.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('does not plot down probes as latency 0', () => {
|
||||
const { points } = toAlignedSeries([
|
||||
probe({
|
||||
|
||||
@@ -124,8 +124,13 @@ export function toAlignedSeries(items: UptimeProbe[]): {
|
||||
}
|
||||
|
||||
const ok = probeOk(item)
|
||||
row[`${key}Ok`] = ok
|
||||
row[key] = ok ? item.latency_ms : null
|
||||
const prevOk = row[`${key}Ok`]
|
||||
row[`${key}Ok`] = prevOk === true || ok
|
||||
if (ok && item.latency_ms != null) {
|
||||
row[key] = item.latency_ms
|
||||
} else if (row[key] === undefined) {
|
||||
row[key] = null
|
||||
}
|
||||
}
|
||||
|
||||
const points = [...buckets.entries()]
|
||||
|
||||
@@ -3,28 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture for FramePanel theming:
|
||||
*
|
||||
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
||||
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
||||
* border-(--frame-panel-border-color). This means:
|
||||
*
|
||||
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
||||
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
||||
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
||||
* :not() or !important needed
|
||||
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
|
||||
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
|
||||
* So variant="inverse" re-points every panel from one place, and a consumer's
|
||||
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
|
||||
*/
|
||||
const frameVariants = cva(
|
||||
[
|
||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
|
||||
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
|
||||
// Each rung is the radius that style's own shadcn Card resolves through, so
|
||||
// a Frame and a Card side by side agree. lyra/sera are 0px, NOT
|
||||
// var(--radius-none): no such token exists, and it only reached 0 by being
|
||||
// invalid, which also left --frame-radius empty for anything reading it.
|
||||
"[--frame-radius:var(--radius-xl)]",
|
||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
|
||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
||||
// Default panel token values — overridden per-variant below
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
|
||||
// Luma alone re-times the frame: wider gap and padding, roomier bars.
|
||||
"",
|
||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||
// Concentric: the panel nests inside the frame's corner rather than copying
|
||||
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
|
||||
// that keeps the two arcs parallel. `ghost` drops the border term (no
|
||||
// border), `dense` pins it to the frame radius (panels sit flush).
|
||||
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
@@ -32,14 +34,18 @@ const frameVariants = cva(
|
||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||
inverse:
|
||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||
ghost: "",
|
||||
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
|
||||
},
|
||||
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
|
||||
// against a body py of 2/3.5/4/5, while px stays level with the body so
|
||||
// header, content and footer left-align. xs floors at 0.5 (2px), below
|
||||
// which it stops reading as padding. No style-*.css overrides these.
|
||||
spacing: {
|
||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
|
||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
|
||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
|
||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
|
||||
default:
|
||||
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
|
||||
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
|
||||
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
|
||||
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
|
||||
},
|
||||
stacked: {
|
||||
true: [
|
||||
@@ -55,8 +61,9 @@ const frameVariants = cva(
|
||||
],
|
||||
},
|
||||
dense: {
|
||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
|
||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||
// Parent selectors, not CSS vars: these are positional. Panels are
|
||||
// pulled flush (-mx-px), so corners align with the frame's own radius.
|
||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
@@ -98,13 +105,11 @@ function FramePanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
||||
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
||||
// via className overrides these by Tailwind source order - no ! needed.
|
||||
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
||||
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
/* `fit` sizes the panel to its content; otherwise it grows to fill
|
||||
the frame. */
|
||||
!fit && "grow",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
|
||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||
className
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_BINDING_HEALTH,
|
||||
emptyAddressBlock,
|
||||
hydrateAddressBlock,
|
||||
patchAddressIpMeta,
|
||||
toBindingDrafts,
|
||||
toDomainsPayload,
|
||||
type AddressBlockState,
|
||||
@@ -218,8 +219,8 @@ export function ServiceEditSheet({
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп.
|
||||
FQDN.
|
||||
Общие FQDN на весь пул IP. У каждого адреса можно указать несколько доп.
|
||||
FQDN, в том числе wildcard.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -282,6 +283,12 @@ export function ServiceEditSheet({
|
||||
idPrefix="service-health"
|
||||
value={primaryHealthValue}
|
||||
onChange={handlePrimaryHealthChange}
|
||||
ips={address.nodes.map((node) => node.ip)}
|
||||
weights={address.target_ip_weights}
|
||||
priorities={address.target_ip_priorities}
|
||||
onMetaChange={(ip, meta) =>
|
||||
setAddress((current) => patchAddressIpMeta(current, ip, meta))
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import {
|
||||
ServiceDetailGrid,
|
||||
type ServiceFqdnRow,
|
||||
} from '@/components/services/service-detail-grid'
|
||||
import { useServiceDetailActions } from '@/components/services/service-detail-context'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import {
|
||||
deleteServiceNode,
|
||||
invalidateServiceDetail,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceNodesQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
interface ServiceNodeRow {
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export function ServiceAssetSection({
|
||||
serviceId,
|
||||
section,
|
||||
}: {
|
||||
serviceId: string
|
||||
section: 'ip' | 'fqdn' | 'nodes' | 'ssl'
|
||||
}) {
|
||||
const id = Number(serviceId)
|
||||
const queryClient = useQueryClient()
|
||||
const actions = useServiceDetailActions()
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||
|
||||
const service = viewQuery.data
|
||||
const probes = logQuery.data?.items ?? []
|
||||
const nodes = Array.isArray(nodesQuery.data)
|
||||
? (nodesQuery.data as ServiceNodeRow[])
|
||||
: []
|
||||
|
||||
const toggleIpMutation = useMutation({
|
||||
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
|
||||
onSuccess: async (_data, { enabled }) => {
|
||||
await invalidateServiceDetail(queryClient, id)
|
||||
toast.success(
|
||||
enabled
|
||||
? 'IP включён и добавлен в DNS-привязки'
|
||||
: 'IP выключен и снят с DNS-привязок',
|
||||
)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
|
||||
},
|
||||
onSettled: () => setTogglingIp(null),
|
||||
})
|
||||
|
||||
const deleteNodeMut = useMutation({
|
||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода удалена')
|
||||
await invalidateServiceDetail(queryClient, id)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить ноду')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={viewQuery.isLoading}
|
||||
isError={viewQuery.isError}
|
||||
error={viewQuery.error}
|
||||
onRetry={() => {
|
||||
void viewQuery.refetch()
|
||||
}}
|
||||
>
|
||||
{!service ? (
|
||||
<EmptyState
|
||||
title="Сервис не найден"
|
||||
description="Вернитесь в каталог и выберите сервис."
|
||||
/>
|
||||
) : (
|
||||
<ServiceDetailGrid
|
||||
service={service}
|
||||
nodes={nodes}
|
||||
probes={probes}
|
||||
section={section}
|
||||
togglingIp={togglingIp}
|
||||
isLoading={nodesQuery.isLoading}
|
||||
onToggleIp={(ip, enabled) => {
|
||||
setTogglingIp(ip)
|
||||
toggleIpMutation.mutate({ ip, enabled })
|
||||
}}
|
||||
onChangeIp={(row: ServiceFqdnRow) => actions.openChangeIp(row)}
|
||||
onChangeDomain={() => actions.openChangeDomain()}
|
||||
onAddNode={actions.openAddNode}
|
||||
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createContext, createElement, useContext, type ReactNode } from 'react'
|
||||
|
||||
import type { ServiceFqdnRow } from '@/components/services/service-detail-grid'
|
||||
|
||||
export interface ServiceDetailActions {
|
||||
openAddNode: () => void
|
||||
openChangeIp: (row: ServiceFqdnRow) => void
|
||||
openChangeDomain: () => void
|
||||
}
|
||||
|
||||
const ServiceDetailActionsContext = createContext<ServiceDetailActions | null>(null)
|
||||
|
||||
export function ServiceDetailActionsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ServiceDetailActions
|
||||
children: ReactNode
|
||||
}) {
|
||||
return createElement(ServiceDetailActionsContext.Provider, { value }, children)
|
||||
}
|
||||
|
||||
export function useServiceDetailActions() {
|
||||
const value = useContext(ServiceDetailActionsContext)
|
||||
if (!value) {
|
||||
throw new Error('Действия сервиса доступны только внутри карточки сервиса')
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
latestHealthByIp,
|
||||
resolveIpDisplayHealth,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
import { certRelativeBadge } from '@/components/columns/certificates-columns'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatDate } from '@/lib/format'
|
||||
@@ -99,8 +104,12 @@ function mapNodeHealth(status: string): HealthStatus {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function buildIpRows(service: ServiceView): ServiceIpRow[] {
|
||||
function buildIpRows(
|
||||
service: ServiceView,
|
||||
probes: readonly HealthLogProbe[] = [],
|
||||
): ServiceIpRow[] {
|
||||
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
|
||||
const liveByIp = latestHealthByIp(probes)
|
||||
const weights = Object.assign(
|
||||
{},
|
||||
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
|
||||
@@ -113,19 +122,25 @@ function buildIpRows(service: ServiceView): ServiceIpRow[] {
|
||||
|
||||
return service.ips.map((ip) => {
|
||||
const health = healthByIp.get(ip)
|
||||
const live = liveByIp.get(ip)
|
||||
const status = resolveIpDisplayHealth(health?.status, live?.status)
|
||||
const extras = live && live.status !== 'unknown' ? live : health
|
||||
return {
|
||||
id: ip,
|
||||
ip,
|
||||
status: health?.status ?? 'unknown',
|
||||
status,
|
||||
enabled: service.ip_enabled[ip] !== false,
|
||||
active: activeSet.has(ip),
|
||||
weight: weights[ip] ?? 1,
|
||||
priority: priorities[ip] ?? 1,
|
||||
latency_ms: health?.latency_ms ?? null,
|
||||
last_checked_at: health?.last_checked_at ?? null,
|
||||
last_error: health?.last_error ?? null,
|
||||
colo: health?.colo ?? null,
|
||||
provider: health?.provider ?? null,
|
||||
latency_ms: extras?.latency_ms ?? null,
|
||||
last_checked_at: extras?.last_checked_at ?? null,
|
||||
last_error:
|
||||
live && live.status !== 'unknown'
|
||||
? live.last_error
|
||||
: (health?.last_error ?? null),
|
||||
colo: extras?.colo ?? null,
|
||||
provider: extras?.provider ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -151,17 +166,23 @@ function buildNodeRows(
|
||||
weight: number
|
||||
priority: number
|
||||
}>,
|
||||
probes: readonly HealthLogProbe[] = [],
|
||||
): ServiceNodeRow[] {
|
||||
return nodes.map((node) => ({
|
||||
id: String(node.id),
|
||||
nodeId: node.id,
|
||||
address: node.address,
|
||||
protocol: node.protocol,
|
||||
port: node.port,
|
||||
health_status: mapNodeHealth(node.health_status),
|
||||
weight: node.weight,
|
||||
priority: node.priority,
|
||||
}))
|
||||
const liveByIp = latestHealthByIp(probes)
|
||||
return nodes.map((node) => {
|
||||
const stored = mapNodeHealth(node.health_status)
|
||||
const live = liveByIp.get(node.address)
|
||||
return {
|
||||
id: String(node.id),
|
||||
nodeId: node.id,
|
||||
address: node.address,
|
||||
protocol: node.protocol,
|
||||
port: node.port,
|
||||
health_status: resolveIpDisplayHealth(stored, live?.status),
|
||||
weight: node.weight,
|
||||
priority: node.priority,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function NameCell({
|
||||
@@ -199,6 +220,7 @@ interface ServiceDetailGridProps {
|
||||
weight: number
|
||||
priority: number
|
||||
}>
|
||||
probes?: readonly HealthLogProbe[]
|
||||
togglingIp: string | null
|
||||
onToggleIp: (ip: string, enabled: boolean) => void
|
||||
onChangeIp: (row: ServiceFqdnRow) => void
|
||||
@@ -206,11 +228,14 @@ interface ServiceDetailGridProps {
|
||||
onAddNode: () => void
|
||||
onDeleteNode: (nodeId: number) => void
|
||||
isLoading?: boolean
|
||||
/** Фиксирует одну секцию без внутренних вкладок IP / FQDN / Ноды / SSL. */
|
||||
section?: 'ip' | 'fqdn' | 'nodes' | 'ssl'
|
||||
}
|
||||
|
||||
export function ServiceDetailGrid({
|
||||
service,
|
||||
nodes,
|
||||
probes = [],
|
||||
togglingIp,
|
||||
onToggleIp,
|
||||
onChangeIp,
|
||||
@@ -218,8 +243,10 @@ export function ServiceDetailGrid({
|
||||
onAddNode,
|
||||
onDeleteNode,
|
||||
isLoading = false,
|
||||
section,
|
||||
}: ServiceDetailGridProps) {
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
|
||||
const [tabState, setTab] = useState<(typeof TABS)[number]['id']>(section ?? 'ip')
|
||||
const tab = section ?? tabState
|
||||
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
|
||||
createFilter('ip', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
@@ -283,9 +310,9 @@ export function ServiceDetailGrid({
|
||||
},
|
||||
})
|
||||
|
||||
const ipRows = useMemo(() => buildIpRows(service), [service])
|
||||
const ipRows = useMemo(() => buildIpRows(service, probes), [service, probes])
|
||||
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
||||
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
|
||||
const nodeRows = useMemo(() => buildNodeRows(nodes, probes), [nodes, probes])
|
||||
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
|
||||
|
||||
const tabs = TABS.map((entry) => ({
|
||||
@@ -648,17 +675,34 @@ export function ServiceDetailGrid({
|
||||
[patchCertMonitoring],
|
||||
)
|
||||
|
||||
const sharedTabs = {
|
||||
tabs,
|
||||
activeTab: tab,
|
||||
onTabChange: (id: string) => setTab(id as typeof tab),
|
||||
}
|
||||
const heading =
|
||||
section === 'nodes'
|
||||
? { title: 'Ноды', description: 'Адреса пула и состояние проверок' }
|
||||
: section === 'fqdn'
|
||||
? { title: 'Поддомены', description: 'FQDN, привязанные к сервису' }
|
||||
: section === 'ip'
|
||||
? {
|
||||
title: 'Маршрутизация',
|
||||
description: 'Веса, приоритеты и включённые адреса',
|
||||
}
|
||||
: {
|
||||
title: 'Активы сервиса',
|
||||
description: 'IP, FQDN и ноды этого сервиса',
|
||||
}
|
||||
|
||||
const sharedTabs = section
|
||||
? {}
|
||||
: {
|
||||
tabs,
|
||||
activeTab: tab,
|
||||
onTabChange: (id: string) => setTab(id as typeof tab),
|
||||
}
|
||||
|
||||
if (tab === 'fqdn') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={fqdnFilterFields}
|
||||
filters={fqdnFilters}
|
||||
@@ -688,8 +732,8 @@ export function ServiceDetailGrid({
|
||||
if (tab === 'nodes') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={nodeFilterFields}
|
||||
filters={nodeFilters}
|
||||
@@ -710,10 +754,25 @@ export function ServiceDetailGrid({
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button size="sm" onClick={onAddNode}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
section ? undefined : (
|
||||
<Button size="sm" onClick={onAddNode}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
emptyState={
|
||||
section === 'nodes'
|
||||
? {
|
||||
title: 'Нет нод',
|
||||
description: 'Добавьте ноду в пул сервиса.',
|
||||
action: (
|
||||
<Button type="button" variant="outline" onClick={onAddNode}>
|
||||
Добавить ноду
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -722,8 +781,12 @@ export function ServiceDetailGrid({
|
||||
if (tab === 'ssl') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN, ноды и SSL этого сервиса"
|
||||
title={section === 'ssl' ? 'SSL' : 'Активы сервиса'}
|
||||
description={
|
||||
section === 'ssl'
|
||||
? 'Сертификаты привязанных FQDN'
|
||||
: 'IP, FQDN, ноды и SSL этого сервиса'
|
||||
}
|
||||
{...sharedTabs}
|
||||
filterFields={sslFilterFields}
|
||||
filters={sslFilters}
|
||||
@@ -767,8 +830,8 @@ export function ServiceDetailGrid({
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={ipFilterFields}
|
||||
filters={ipFilters}
|
||||
|
||||
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
|
||||
interface ServiceIpListProps {
|
||||
ips: string[]
|
||||
ipHealth?: ServiceView['ip_health']
|
||||
healthCheckEnabled?: boolean
|
||||
ipEnabled?: Record<string, boolean>
|
||||
togglingIp?: string | null
|
||||
ipToggleDisabled?: boolean
|
||||
@@ -151,6 +152,7 @@ interface ServiceIpListProps {
|
||||
export function ServiceIpList({
|
||||
ips,
|
||||
ipHealth = [],
|
||||
healthCheckEnabled = true,
|
||||
ipEnabled = {},
|
||||
togglingIp = null,
|
||||
ipToggleDisabled = false,
|
||||
@@ -184,6 +186,10 @@ export function ServiceIpList({
|
||||
{visible.map((ip) => {
|
||||
const health = healthByIp.get(ip)
|
||||
const enabled = ipEnabled[ip] !== false
|
||||
const monitored = healthCheckEnabled && enabled
|
||||
const badgeStatus = monitored
|
||||
? (health?.status ?? 'unknown')
|
||||
: 'disabled'
|
||||
return (
|
||||
<Item
|
||||
key={ip}
|
||||
@@ -192,7 +198,7 @@ export function ServiceIpList({
|
||||
>
|
||||
<ItemMedia>
|
||||
<HealthCheckBadge
|
||||
status={health?.status ?? 'unknown'}
|
||||
status={badgeStatus}
|
||||
latencyMs={health?.latency_ms}
|
||||
lastCheckedAt={health?.last_checked_at}
|
||||
lastError={health?.last_error}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, UnplugIcon } from 'lucide-react'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { ServiceHealthMonitor } from '@/components/reui-kit'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
import type { FailoverHistoryItem } from '@/lib/failover-events'
|
||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||
import {
|
||||
serviceFailoverLogQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
function failoverHistory(items: readonly FailoverLogEntry[]): FailoverHistoryItem[] {
|
||||
return items.map((item) => ({
|
||||
id: `dns:${item.id}`,
|
||||
ip: item.ip,
|
||||
fqdn: item.fqdn,
|
||||
action: item.action,
|
||||
created_at: item.created_at,
|
||||
copy:
|
||||
item.action === 'removed'
|
||||
? `${item.ip} убрана с ${item.fqdn}`
|
||||
: `${item.ip} добавлена на ${item.fqdn}`,
|
||||
source: 'dns',
|
||||
}))
|
||||
}
|
||||
|
||||
function hasLatencySeries(items: readonly HealthLogProbe[]) {
|
||||
return items.some((item) => item.latency_ms != null && item.checked_at.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Health карточки сервиса: график задержки и timeline failover
|
||||
* только по строкам журналов, без синтетических точек.
|
||||
*
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function ServiceHealthSection({ serviceId }: { serviceId: string }) {
|
||||
const id = Number(serviceId)
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const failoverQuery = useQuery(serviceFailoverLogQueryOptions(id))
|
||||
|
||||
const probes = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items])
|
||||
const failoverItems = useMemo(
|
||||
() => failoverQuery.data?.items ?? [],
|
||||
[failoverQuery.data?.items],
|
||||
)
|
||||
const history = useMemo(() => failoverHistory(failoverItems), [failoverItems])
|
||||
const showLatency = hasLatencySeries(probes)
|
||||
|
||||
const enabledProviders = useMemo(
|
||||
() => enabledHealthProviders(viewQuery.data?.domains ?? []),
|
||||
[viewQuery.data?.domains],
|
||||
)
|
||||
const providerStatuses = useMemo(
|
||||
() => providerHealthStatuses(probes, enabledProviders),
|
||||
[probes, enabledProviders],
|
||||
)
|
||||
|
||||
const isLoading = logQuery.isLoading || failoverQuery.isLoading
|
||||
const isError = logQuery.isError || failoverQuery.isError
|
||||
const error = logQuery.error ?? failoverQuery.error
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void logQuery.refetch()
|
||||
void failoverQuery.refetch()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{showLatency ? (
|
||||
<ServiceHealthMonitor
|
||||
items={probes}
|
||||
enabledProviders={enabledProviders}
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isFetching}
|
||||
/>
|
||||
) : (
|
||||
<Frame className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Задержка и ошибки</FrameTitle>
|
||||
<FrameDescription>Проба health-check · последние 24 часа</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет ряда за 24 часа"
|
||||
description="Журнал health-log не содержит замеров задержки. Точки не подставляются."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>История failover</FrameTitle>
|
||||
<FrameDescription>Переключения между нодами</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{history.length > 0 ? (
|
||||
<FailoverTimeline events={[]} history={history} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={UnplugIcon}
|
||||
title="Нет событий failover"
|
||||
description="Журнал failover пуст — timeline строится только по его записям."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { ServiceAssetSection } from '@/components/services/service-asset-section'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { serviceOverviewQueryOptions, serviceViewQueryOptions } from '@/queries'
|
||||
|
||||
const LB_LABEL: Record<ServiceView['lb_mode'], string> = {
|
||||
round_robin: 'Round robin',
|
||||
failover: 'Failover',
|
||||
weighted: 'По весам',
|
||||
}
|
||||
|
||||
interface OverviewRouting {
|
||||
routing_strategy?: string
|
||||
active_addresses?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview: https://reui.io/preview/base/settings-16
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function ServiceRoutingSection({ serviceId }: { serviceId: string }) {
|
||||
const id = Number(serviceId)
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||
const service = viewQuery.data
|
||||
const overview = overviewQuery.data as OverviewRouting | undefined
|
||||
const active = overview?.active_addresses ?? service?.active_ips ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{service ? (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Маршрутизация</FrameTitle>
|
||||
<FrameDescription>Режим балансировки и активные адреса</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<SettingRow title="Режим">
|
||||
<Badge variant="outline" size="sm">
|
||||
{LB_LABEL[service.lb_mode]}
|
||||
</Badge>
|
||||
</SettingRow>
|
||||
<SettingRow title="Стратегия">
|
||||
<span className="text-sm">
|
||||
{overview?.routing_strategy ?? LB_LABEL[service.lb_mode]}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Активные адреса" last>
|
||||
<span className="text-sm font-mono">{active.join(', ') || '—'}</span>
|
||||
</SettingRow>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : null}
|
||||
<ServiceAssetSection serviceId={serviceId} section="ip" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Repeat2Icon,
|
||||
ScaleIcon,
|
||||
ServerIcon,
|
||||
UnplugIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
ServiceIpList,
|
||||
} from '@/components/services/service-fqdn-list'
|
||||
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import { uniqueIpCount } from '@/lib/failover-events'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -71,11 +73,39 @@ const LB_MODE_META: Record<
|
||||
weighted: {
|
||||
icon: ScaleIcon,
|
||||
className: 'text-info',
|
||||
label: 'Weighted (веса)',
|
||||
label: 'Веса (подмена IP)',
|
||||
},
|
||||
}
|
||||
|
||||
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||
export function LbModeTile({
|
||||
mode,
|
||||
hasPool = true,
|
||||
}: {
|
||||
mode: LbMode
|
||||
hasPool?: boolean
|
||||
}) {
|
||||
if (!hasPool) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="без резервирования"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<UnplugIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>без резервирования</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = LB_MODE_META[mode]
|
||||
const Icon = meta.icon
|
||||
|
||||
@@ -148,7 +178,10 @@ export function ServiceUnitCard({
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<LbModeTile
|
||||
mode={service.lb_mode}
|
||||
hasPool={uniqueIpCount(service.ips) >= 2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||
@@ -240,6 +273,10 @@ export function ServiceUnitCard({
|
||||
alignWithMenu
|
||||
ips={service.ips ?? []}
|
||||
ipHealth={service.ip_health ?? []}
|
||||
healthCheckEnabled={
|
||||
service.enabled &&
|
||||
(service.domains ?? []).some((domain) => domain.health_check_enabled)
|
||||
}
|
||||
ipEnabled={service.ip_enabled ?? {}}
|
||||
ipToggleDisabled={togglingId === service.id}
|
||||
togglingIp={togglingIp}
|
||||
|
||||
@@ -249,11 +249,11 @@ export function ServicesGroupedCatalog({
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
title="Нет сервисов"
|
||||
description={
|
||||
domainLabel
|
||||
? `Нет сервисов с привязками к ${domainLabel}`
|
||||
: 'Сначала создайте группу, затем добавьте сервисы.'
|
||||
: 'Создайте сервис и добавьте поддомен с нодами.'
|
||||
}
|
||||
action={emptyAction}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { applyAppearance, readContrast, readDensity } from '@/lib/appearance-mode'
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
applyAppearance(readContrast(), readDensity())
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { deriveSystemAttention, type SystemAttention } from '@/lib/system-attention'
|
||||
import {
|
||||
certificatesQueryOptions,
|
||||
certSummaryQueryOptions,
|
||||
domainsListQueryOptions,
|
||||
notificationLogQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
|
||||
type ReadyResponse = {
|
||||
status: string
|
||||
}
|
||||
|
||||
export const SystemAttentionContext = createContext<SystemAttention | null>(null)
|
||||
|
||||
export function useSystemAttentionData(): SystemAttention {
|
||||
const readyQ = useQuery({
|
||||
queryKey: ['ready'],
|
||||
queryFn: async (): Promise<ReadyResponse> => {
|
||||
const res = await fetch('/ready')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<ReadyResponse>
|
||||
},
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: POLL_MS })
|
||||
const certSummaryQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: POLL_MS })
|
||||
const certificatesQ = useQuery({ ...certificatesQueryOptions(), refetchInterval: POLL_MS })
|
||||
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: POLL_MS })
|
||||
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: POLL_MS })
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
deriveSystemAttention({
|
||||
services: servicesQ.data,
|
||||
certificates: certificatesQ.data,
|
||||
certSummary: certSummaryQ.data,
|
||||
notifications: notifyQ.data,
|
||||
domains: domainsQ.data,
|
||||
readyStatus: readyQ.data?.status,
|
||||
readyIsSuccess: readyQ.isSuccess,
|
||||
readyIsError: readyQ.isError,
|
||||
}),
|
||||
[
|
||||
certificatesQ.data,
|
||||
certSummaryQ.data,
|
||||
domainsQ.data,
|
||||
notifyQ.data,
|
||||
readyQ.data?.status,
|
||||
readyQ.isError,
|
||||
readyQ.isSuccess,
|
||||
servicesQ.data,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function useSystemAttention() {
|
||||
const attention = useContext(SystemAttentionContext)
|
||||
if (!attention) {
|
||||
throw new Error('useSystemAttention должен вызываться внутри SystemAttentionProvider')
|
||||
}
|
||||
return attention
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const CONTRAST_KEY = 'cfdm.contrast'
|
||||
const DENSITY_KEY = 'cfdm.density'
|
||||
|
||||
export type ContrastMode = 'standard' | 'high'
|
||||
export type DensityMode = 'normal' | 'compact'
|
||||
|
||||
export function readContrast(): ContrastMode {
|
||||
try {
|
||||
return localStorage.getItem(CONTRAST_KEY) === 'high' ? 'high' : 'standard'
|
||||
} catch {
|
||||
return 'standard'
|
||||
}
|
||||
}
|
||||
|
||||
export function readDensity(): DensityMode {
|
||||
try {
|
||||
return localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'normal'
|
||||
} catch {
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAppearance(contrast: ContrastMode, density: DensityMode) {
|
||||
document.documentElement.dataset.contrast = contrast
|
||||
document.documentElement.dataset.density = density
|
||||
}
|
||||
|
||||
export function writeContrast(contrast: ContrastMode) {
|
||||
try {
|
||||
localStorage.setItem(CONTRAST_KEY, contrast)
|
||||
} catch {
|
||||
// хранилище недоступно
|
||||
}
|
||||
applyAppearance(contrast, readDensity())
|
||||
}
|
||||
|
||||
export function writeDensity(density: DensityMode) {
|
||||
try {
|
||||
localStorage.setItem(DENSITY_KEY, density)
|
||||
} catch {
|
||||
// хранилище недоступно
|
||||
}
|
||||
applyAppearance(readContrast(), density)
|
||||
}
|
||||
@@ -18,6 +18,31 @@ describe('getBreadcrumbs', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the service name on nested service tabs', () => {
|
||||
expect(
|
||||
getBreadcrumbs('/services/9/health', { '/services/9': '[IHOR] Москва' }),
|
||||
).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: '[IHOR] Москва', href: '/services/9' },
|
||||
{ label: 'Health', href: '/services/9/health' },
|
||||
])
|
||||
expect(getBreadcrumbs('/services/9/routing')).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: 'Сервис', href: '/services/9' },
|
||||
{ label: 'Маршрутизация', href: '/services/9/routing' },
|
||||
])
|
||||
expect(getBreadcrumbs('/services/9/nodes')).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: 'Сервис', href: '/services/9' },
|
||||
{ label: 'Ноды', href: '/services/9/nodes' },
|
||||
])
|
||||
expect(getBreadcrumbs('/services/9/subdomains')).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: 'Сервис', href: '/services/9' },
|
||||
{ label: 'Поддомены', href: '/services/9/subdomains' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not reuse the section href for the parent crumb', () => {
|
||||
const crumbs = getBreadcrumbs('/settings/appearance')
|
||||
const hrefs = crumbs.map((crumb) => crumb.href)
|
||||
|
||||
@@ -38,10 +38,24 @@ export function getBreadcrumbs(
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (path.match(/^\/services\/\d+$/)) {
|
||||
const serviceMatch = path.match(
|
||||
/^\/services\/(\d+)(?:\/(nodes|health|routing|subdomains))?$/,
|
||||
)
|
||||
if (serviceMatch) {
|
||||
const servicePath = `/services/${serviceMatch[1]}`
|
||||
const section = serviceMatch[2]
|
||||
const sectionLabels: Record<string, string> = {
|
||||
nodes: 'Ноды',
|
||||
health: 'Health',
|
||||
routing: 'Маршрутизация',
|
||||
subdomains: 'Поддомены',
|
||||
}
|
||||
return [
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
|
||||
{ label: dynamicLabels[servicePath] ?? 'Сервис', href: servicePath },
|
||||
...(section
|
||||
? [{ label: sectionLabels[section] ?? section, href: path }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
failoverEventCopy,
|
||||
isFailoverEventStatus,
|
||||
mergeFailoverHistory,
|
||||
toFailoverEvents,
|
||||
toIpAliveTransitions,
|
||||
type FailoverBindingPool,
|
||||
type FailoverHealthInput,
|
||||
} from '@/lib/failover-events'
|
||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||
import type { HealthLogProbe } from '@/lib/health-log'
|
||||
|
||||
function row(
|
||||
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
|
||||
): FailoverHealthInput {
|
||||
return {
|
||||
status: 'up',
|
||||
consecutive_failures: 0,
|
||||
last_error: null,
|
||||
last_checked_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const mskHip: FailoverBindingPool[] = [
|
||||
{
|
||||
fqdn: 'gt.rkns.top',
|
||||
configured: ['130.49.213.153', '93.115.203.183'],
|
||||
active: ['93.115.203.183'],
|
||||
},
|
||||
{
|
||||
fqdn: 'nsgt.rkns.top',
|
||||
configured: ['130.49.213.153'],
|
||||
active: ['130.49.213.153'],
|
||||
},
|
||||
{
|
||||
fqdn: 'rutg.rkns.top',
|
||||
configured: ['93.115.203.183'],
|
||||
active: ['93.115.203.183'],
|
||||
},
|
||||
]
|
||||
|
||||
describe('toFailoverEvents', () => {
|
||||
it('инцидент только при binding down, не при unhealthy ноды', () => {
|
||||
expect(isFailoverEventStatus('down')).toBe(true)
|
||||
expect(isFailoverEventStatus('up')).toBe(false)
|
||||
expect(isFailoverEventStatus('degraded')).toBe(false)
|
||||
expect(isFailoverEventStatus('unknown')).toBe(false)
|
||||
expect(isFailoverEventStatus('unhealthy')).toBe(false)
|
||||
})
|
||||
|
||||
it('один IP у сервиса — не инцидент пула', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({
|
||||
ip: '10.0.0.1',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_error: 'fetch failed',
|
||||
}),
|
||||
],
|
||||
[
|
||||
{
|
||||
fqdn: 'solo.example.com',
|
||||
configured: ['10.0.0.1'],
|
||||
active: [],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('Down без shared pool не событие балансировки', () => {
|
||||
const events = toFailoverEvents([
|
||||
row({
|
||||
ip: '130.49.213.153',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_error: 'fetch failed',
|
||||
}),
|
||||
])
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('OK вне пула не инцидент (standby)', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({ ip: '10.0.0.1', status: 'up' }),
|
||||
row({ ip: '130.49.213.153', status: 'up' }),
|
||||
],
|
||||
[
|
||||
{
|
||||
fqdn: 'pool.example.com',
|
||||
configured: ['10.0.0.1', '130.49.213.153'],
|
||||
active: ['10.0.0.1'],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('unknown и degraded вне пула не инцидент', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({ ip: '10.0.0.1', status: 'up' }),
|
||||
row({ ip: '10.0.0.2', status: 'unknown' }),
|
||||
row({ ip: '10.0.0.3', status: 'degraded' }),
|
||||
],
|
||||
[
|
||||
{
|
||||
fqdn: 'pool.example.com',
|
||||
configured: ['10.0.0.1', '10.0.0.2', '10.0.0.3'],
|
||||
active: ['10.0.0.1'],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('Down на доп. FQDN last-resort, снятая с общего пула', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({
|
||||
ip: '130.49.213.153',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_error: 'fetch failed',
|
||||
last_checked_at: '2026-08-20 07:00:00',
|
||||
}),
|
||||
row({ ip: '93.115.203.183', status: 'up' }),
|
||||
],
|
||||
mskHip,
|
||||
)
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: '130.49.213.153',
|
||||
address: '130.49.213.153',
|
||||
status: 'down',
|
||||
kind: 'removed',
|
||||
fqdns: ['gt.rkns.top'],
|
||||
consecutiveFailures: 9,
|
||||
lastFailureReason: 'fetch failed',
|
||||
lastCheckAt: '2026-08-20 07:00:00',
|
||||
},
|
||||
])
|
||||
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
|
||||
expect(events[0]?.fqdns).not.toContain('nsgt.rkns.top')
|
||||
})
|
||||
|
||||
it('Down только last-resort на своём FQDN', () => {
|
||||
const events = toFailoverEvents(
|
||||
[row({ ip: '130.49.213.153', status: 'down' })],
|
||||
[
|
||||
{
|
||||
fqdn: 'nsgt.rkns.top',
|
||||
configured: ['130.49.213.153'],
|
||||
active: ['130.49.213.153'],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('down без A-записи на configured FQDN — снятие', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({
|
||||
ip: '130.49.213.153',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_error: 'fetch failed',
|
||||
last_checked_at: '2026-08-20 07:00:00',
|
||||
}),
|
||||
],
|
||||
[
|
||||
{
|
||||
fqdn: 'pool.example.com',
|
||||
configured: ['10.0.0.1', '130.49.213.153'],
|
||||
active: ['10.0.0.1'],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: '130.49.213.153',
|
||||
address: '130.49.213.153',
|
||||
status: 'down',
|
||||
kind: 'removed',
|
||||
fqdns: ['pool.example.com'],
|
||||
consecutiveFailures: 9,
|
||||
lastFailureReason: 'fetch failed',
|
||||
lastCheckAt: '2026-08-20 07:00:00',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function probe(
|
||||
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
|
||||
): HealthLogProbe {
|
||||
return {
|
||||
ip: '130.49.213.153',
|
||||
provider: 'local',
|
||||
ok: overrides.status === 'up',
|
||||
latency_ms: 12,
|
||||
colo: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function dns(
|
||||
overrides: Partial<FailoverLogEntry> & Pick<FailoverLogEntry, 'id' | 'action' | 'created_at'>,
|
||||
): FailoverLogEntry {
|
||||
return {
|
||||
service_id: 13,
|
||||
binding_id: 1,
|
||||
fqdn: 'gt.rkns.top',
|
||||
ip: '130.49.213.153',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('toIpAliveTransitions', () => {
|
||||
it('emits leave then return, not the initial up', () => {
|
||||
const transitions = toIpAliveTransitions([
|
||||
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||||
probe({ id: 2, status: 'up', checked_at: '2026-08-20T09:01:00Z' }),
|
||||
probe({ id: 3, status: 'down', checked_at: '2026-08-20T09:02:00Z' }),
|
||||
probe({ id: 4, status: 'down', checked_at: '2026-08-20T09:03:00Z' }),
|
||||
probe({ id: 5, status: 'up', checked_at: '2026-08-20T09:04:00Z' }),
|
||||
])
|
||||
expect(transitions).toEqual([
|
||||
{ id: 'probe:3', ip: '130.49.213.153', alive: false, at: '2026-08-20T09:02:00Z' },
|
||||
{ id: 'probe:5', ip: '130.49.213.153', alive: true, at: '2026-08-20T09:04:00Z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('any-up across providers: leave only when every source is down', () => {
|
||||
const transitions = toIpAliveTransitions([
|
||||
probe({
|
||||
id: 1,
|
||||
provider: 'local',
|
||||
status: 'up',
|
||||
checked_at: '2026-08-20T09:00:00Z',
|
||||
}),
|
||||
probe({
|
||||
id: 2,
|
||||
provider: 'cloudflare',
|
||||
status: 'down',
|
||||
checked_at: '2026-08-20T09:01:00Z',
|
||||
}),
|
||||
probe({
|
||||
id: 3,
|
||||
provider: 'local',
|
||||
status: 'down',
|
||||
checked_at: '2026-08-20T09:02:00Z',
|
||||
}),
|
||||
probe({
|
||||
id: 4,
|
||||
provider: 'local',
|
||||
status: 'up',
|
||||
checked_at: '2026-08-20T09:03:00Z',
|
||||
}),
|
||||
])
|
||||
expect(transitions.map((item) => item.id)).toEqual(['probe:3', 'probe:4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeFailoverHistory', () => {
|
||||
it('fills leave/return from probes when DNS has only the add', () => {
|
||||
const history = mergeFailoverHistory(
|
||||
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
|
||||
[
|
||||
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||||
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
|
||||
probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' }),
|
||||
],
|
||||
mskHip,
|
||||
)
|
||||
expect(history.map((item) => `${item.action}:${item.source}`)).toEqual([
|
||||
'added:dns',
|
||||
'removed:probe',
|
||||
])
|
||||
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
|
||||
expect(history[1]?.copy).toBe(
|
||||
'130.49.213.153 вышла из пула (gt.rkns.top)',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps DNS over a probe return in the same 2-minute window', () => {
|
||||
const history = mergeFailoverHistory(
|
||||
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
|
||||
[probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' })],
|
||||
mskHip,
|
||||
)
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0]?.source).toBe('dns')
|
||||
})
|
||||
|
||||
it('drops probe leave/return when the service has no shared pool', () => {
|
||||
const history = mergeFailoverHistory(
|
||||
[],
|
||||
[
|
||||
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||||
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
|
||||
],
|
||||
[
|
||||
{
|
||||
fqdn: 'solo.example.com',
|
||||
configured: ['130.49.213.153'],
|
||||
active: ['130.49.213.153'],
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(history).toEqual([])
|
||||
})
|
||||
|
||||
it('drops dedicated extra-FQDN DNS rows', () => {
|
||||
const history = mergeFailoverHistory(
|
||||
[
|
||||
dns({
|
||||
id: 11,
|
||||
fqdn: 'nsgt.rkns.top',
|
||||
action: 'added',
|
||||
created_at: '2026-08-20 09:26:00',
|
||||
}),
|
||||
],
|
||||
[],
|
||||
mskHip,
|
||||
)
|
||||
expect(history).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
bestAliveHealthStatus,
|
||||
probeTime,
|
||||
type HealthLogProbe,
|
||||
type HealthLogStatus,
|
||||
} from '@/lib/health-log'
|
||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||
|
||||
export type FailoverEventKind = 'removed' | 'last-resort'
|
||||
|
||||
export interface FailoverHistoryItem {
|
||||
id: string
|
||||
ip: string
|
||||
fqdn: string
|
||||
action: 'added' | 'removed'
|
||||
created_at: string
|
||||
copy: string
|
||||
source: 'dns' | 'probe'
|
||||
}
|
||||
|
||||
export interface FailoverEvent {
|
||||
id: string
|
||||
address: string
|
||||
status: string
|
||||
kind: FailoverEventKind
|
||||
fqdns: string[]
|
||||
consecutiveFailures: number
|
||||
lastFailureReason: string | null
|
||||
lastCheckAt?: string | null
|
||||
}
|
||||
|
||||
/** Binding IP health — тот же контур, что таблица активов. */
|
||||
export interface FailoverHealthInput {
|
||||
ip: string
|
||||
status: string
|
||||
consecutive_failures?: number
|
||||
last_error?: string | null
|
||||
last_checked_at?: string | null
|
||||
}
|
||||
|
||||
export interface FailoverBindingPool {
|
||||
fqdn: string
|
||||
configured: readonly string[]
|
||||
active: readonly string[]
|
||||
}
|
||||
|
||||
/** Unique A targets. Duplicates of the same IP are not a pool. */
|
||||
export function uniqueIpCount(ips: readonly string[]): number {
|
||||
return new Set(ips.filter(Boolean)).size
|
||||
}
|
||||
|
||||
/** Shared pool FQDN — two or more unique A targets. */
|
||||
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
|
||||
return uniqueIpCount(binding.configured) >= 2
|
||||
}
|
||||
|
||||
export function hasSharedPool(
|
||||
bindings: readonly FailoverBindingPool[],
|
||||
): boolean {
|
||||
return bindings.some(isSharedPoolBinding)
|
||||
}
|
||||
|
||||
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
|
||||
export function isFailoverEventStatus(status: string): boolean {
|
||||
return status === 'down'
|
||||
}
|
||||
|
||||
export function failoverEventCopy(event: FailoverEvent): string {
|
||||
if (event.kind === 'removed') {
|
||||
return event.fqdns.length > 0
|
||||
? `Снята с ${event.fqdns.join(', ')}`
|
||||
: 'Снята с DNS'
|
||||
}
|
||||
return event.fqdns.length > 0
|
||||
? `Down, в A-записях last-resort на ${event.fqdns.join(', ')}`
|
||||
: 'Down, в A-записях last-resort'
|
||||
}
|
||||
|
||||
/**
|
||||
* Инциденты пула только если у сервиса есть shared FQDN (2+ уникальных IP).
|
||||
* Один IP — не балансировка и не вывод из пула; Down смотрит health-монитор.
|
||||
*/
|
||||
export function toFailoverEvents(
|
||||
ipHealth: readonly FailoverHealthInput[],
|
||||
bindings: readonly FailoverBindingPool[] = [],
|
||||
): FailoverEvent[] {
|
||||
if (!hasSharedPool(bindings)) return []
|
||||
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
|
||||
const removedFqdns: string[] = []
|
||||
const lastResortFqdns: string[] = []
|
||||
for (const binding of bindings) {
|
||||
if (!isSharedPoolBinding(binding)) continue
|
||||
const configured = binding.configured.includes(row.ip)
|
||||
const active = binding.active.includes(row.ip)
|
||||
if (configured && !active) removedFqdns.push(binding.fqdn)
|
||||
else if (configured && active) lastResortFqdns.push(binding.fqdn)
|
||||
}
|
||||
const kind: FailoverEventKind =
|
||||
removedFqdns.length > 0 ? 'removed' : 'last-resort'
|
||||
return {
|
||||
id: row.ip,
|
||||
address: row.ip,
|
||||
status: row.status,
|
||||
kind,
|
||||
fqdns: kind === 'removed' ? removedFqdns : lastResortFqdns,
|
||||
consecutiveFailures: row.consecutive_failures ?? 0,
|
||||
lastFailureReason: row.last_error ?? null,
|
||||
lastCheckAt: row.last_checked_at ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const DEDUPE_WINDOW_MS = 2 * 60 * 1000
|
||||
|
||||
function fqdnsForIp(
|
||||
ip: string,
|
||||
bindings: readonly FailoverBindingPool[],
|
||||
): string[] {
|
||||
return bindings
|
||||
.filter((binding) => isSharedPoolBinding(binding) && binding.configured.includes(ip))
|
||||
.map((binding) => binding.fqdn)
|
||||
}
|
||||
|
||||
function isPoolFqdn(
|
||||
fqdn: string,
|
||||
bindings: readonly FailoverBindingPool[],
|
||||
): boolean {
|
||||
const binding = bindings.find((item) => item.fqdn === fqdn)
|
||||
if (!binding) return true
|
||||
return isSharedPoolBinding(binding)
|
||||
}
|
||||
|
||||
function fqdnLabel(fqdns: string[]): string {
|
||||
return fqdns.join(', ') || 'пул'
|
||||
}
|
||||
|
||||
function isAliveStatus(status: HealthLogStatus): boolean {
|
||||
return status === 'up' || status === 'degraded'
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP any-up flips: down → вышла из пула, up после down → вернулась.
|
||||
* Initial state is not an event.
|
||||
*/
|
||||
export function toIpAliveTransitions(
|
||||
probes: readonly HealthLogProbe[],
|
||||
): Array<{ id: string; ip: string; alive: boolean; at: string }> {
|
||||
const byIp = new Map<string, HealthLogProbe[]>()
|
||||
for (const item of probes) {
|
||||
const list = byIp.get(item.ip)
|
||||
if (list) list.push(item)
|
||||
else byIp.set(item.ip, [item])
|
||||
}
|
||||
|
||||
const out: Array<{ id: string; ip: string; alive: boolean; at: string }> = []
|
||||
for (const [ip, list] of byIp) {
|
||||
list.sort(
|
||||
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||
)
|
||||
const latestByProvider = new Map<string, HealthLogProbe>()
|
||||
let prevAlive: boolean | undefined
|
||||
for (const probe of list) {
|
||||
latestByProvider.set(probe.provider, probe)
|
||||
const status = bestAliveHealthStatus(
|
||||
[...latestByProvider.values()].map((item) => item.status),
|
||||
)
|
||||
if (status === 'unknown') continue
|
||||
const alive = isAliveStatus(status)
|
||||
if (prevAlive !== undefined && alive !== prevAlive) {
|
||||
out.push({
|
||||
id: `probe:${probe.id}`,
|
||||
ip,
|
||||
alive,
|
||||
at: probe.checked_at,
|
||||
})
|
||||
}
|
||||
prevAlive = alive
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function dnsHistoryItem(item: FailoverLogEntry): FailoverHistoryItem {
|
||||
return {
|
||||
id: `dns:${item.id}`,
|
||||
ip: item.ip,
|
||||
fqdn: item.fqdn,
|
||||
action: item.action,
|
||||
created_at: item.created_at,
|
||||
copy:
|
||||
item.action === 'removed'
|
||||
? `${item.ip} убрана с ${item.fqdn}`
|
||||
: `${item.ip} добавлена на ${item.fqdn}`,
|
||||
source: 'dns',
|
||||
}
|
||||
}
|
||||
|
||||
function probeHistoryItem(
|
||||
transition: { id: string; ip: string; alive: boolean; at: string },
|
||||
bindings: readonly FailoverBindingPool[],
|
||||
): FailoverHistoryItem {
|
||||
const fqdns = fqdnsForIp(transition.ip, bindings)
|
||||
const fqdn = fqdnLabel(fqdns)
|
||||
const action = transition.alive ? 'added' : 'removed'
|
||||
return {
|
||||
id: transition.id,
|
||||
ip: transition.ip,
|
||||
fqdn,
|
||||
action,
|
||||
created_at: transition.at,
|
||||
copy:
|
||||
action === 'removed'
|
||||
? `${transition.ip} вышла из пула (${fqdn})`
|
||||
: `${transition.ip} вернулась в пул (${fqdn})`,
|
||||
source: 'probe',
|
||||
}
|
||||
}
|
||||
|
||||
function eventTime(value: string): number {
|
||||
return probeTime(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS add/remove + health leave/return, newest first.
|
||||
* Same IP+action within 2 minutes: keep the DNS row (it has a concrete FQDN).
|
||||
*/
|
||||
export function mergeFailoverHistory(
|
||||
dns: readonly FailoverLogEntry[],
|
||||
probes: readonly HealthLogProbe[],
|
||||
bindings: readonly FailoverBindingPool[] = [],
|
||||
): FailoverHistoryItem[] {
|
||||
const merged = [
|
||||
...dns
|
||||
.filter((item) => isPoolFqdn(item.fqdn, bindings))
|
||||
.map(dnsHistoryItem),
|
||||
...toIpAliveTransitions(probes)
|
||||
.filter((transition) => fqdnsForIp(transition.ip, bindings).length > 0)
|
||||
.map((transition) => probeHistoryItem(transition, bindings)),
|
||||
]
|
||||
merged.sort(
|
||||
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
|
||||
)
|
||||
|
||||
const kept: FailoverHistoryItem[] = []
|
||||
for (const item of merged) {
|
||||
const duplicate = kept.find(
|
||||
(other) =>
|
||||
other.ip === item.ip &&
|
||||
other.action === item.action &&
|
||||
Math.abs(eventTime(other.created_at) - eventTime(item.created_at)) <=
|
||||
DEDUPE_WINDOW_MS,
|
||||
)
|
||||
if (!duplicate) {
|
||||
kept.push(item)
|
||||
continue
|
||||
}
|
||||
if (duplicate.source === 'probe' && item.source === 'dns') {
|
||||
kept[kept.indexOf(duplicate)] = item
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
bestAliveHealthStatus,
|
||||
collapseStatusChanges,
|
||||
enabledHealthProviders,
|
||||
latestHealthByIp,
|
||||
providerHealthStatuses,
|
||||
resolveIpDisplayHealth,
|
||||
resolveServiceDisplayHealth,
|
||||
worstHealthStatus,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
@@ -73,7 +77,7 @@ describe('enabledHealthProviders', () => {
|
||||
})
|
||||
|
||||
describe('providerHealthStatuses', () => {
|
||||
it('uses worst latest-per-ip status', () => {
|
||||
it('uses any-up among latest-per-ip statuses', () => {
|
||||
const items = [
|
||||
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
|
||||
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
|
||||
@@ -84,7 +88,7 @@ describe('providerHealthStatuses', () => {
|
||||
checked_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
]
|
||||
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
|
||||
expect(providerHealthStatuses(items, ['local']).local).toBe('up')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,3 +99,70 @@ describe('worstHealthStatus', () => {
|
||||
expect(worstHealthStatus([])).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bestAliveHealthStatus', () => {
|
||||
it('is up if any IP is up', () => {
|
||||
expect(bestAliveHealthStatus(['up', 'down'])).toBe('up')
|
||||
expect(bestAliveHealthStatus(['degraded', 'down'])).toBe('degraded')
|
||||
expect(bestAliveHealthStatus(['down'])).toBe('down')
|
||||
expect(bestAliveHealthStatus([])).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('latestHealthByIp', () => {
|
||||
it('any-up among latest-per-provider probes', () => {
|
||||
const items = [
|
||||
probe({
|
||||
id: 1,
|
||||
ip: '130.49.213.153',
|
||||
provider: 'local',
|
||||
status: 'up',
|
||||
checked_at: '2026-08-20T09:00:00Z',
|
||||
}),
|
||||
probe({
|
||||
id: 2,
|
||||
ip: '130.49.213.153',
|
||||
provider: 'cloudflare',
|
||||
status: 'down',
|
||||
checked_at: '2026-08-20T09:00:01Z',
|
||||
}),
|
||||
probe({
|
||||
id: 3,
|
||||
ip: '93.115.203.183',
|
||||
status: 'unknown',
|
||||
checked_at: '2026-08-20T08:59:00Z',
|
||||
}),
|
||||
]
|
||||
const byIp = latestHealthByIp(items)
|
||||
expect(byIp.get('130.49.213.153')?.status).toBe('up')
|
||||
expect(byIp.get('93.115.203.183')?.status).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveIpDisplayHealth', () => {
|
||||
it('prefers a live probe over stored unknown', () => {
|
||||
expect(resolveIpDisplayHealth('unknown', 'up')).toBe('up')
|
||||
expect(resolveIpDisplayHealth('down', 'up')).toBe('up')
|
||||
expect(resolveIpDisplayHealth('up', 'unknown')).toBe('up')
|
||||
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveServiceDisplayHealth', () => {
|
||||
it('shows OK when live probes recovered and stored is still unknown', () => {
|
||||
expect(
|
||||
resolveServiceDisplayHealth(
|
||||
'unknown',
|
||||
[{ ip: '2.59.161.102', status: 'unknown' }],
|
||||
[
|
||||
probe({
|
||||
id: 1,
|
||||
ip: '2.59.161.102',
|
||||
status: 'up',
|
||||
checked_at: '2026-08-20T18:00:00Z',
|
||||
}),
|
||||
],
|
||||
),
|
||||
).toBe('up')
|
||||
})
|
||||
})
|
||||
@@ -98,7 +98,18 @@ export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthL
|
||||
)
|
||||
}
|
||||
|
||||
/** Latest probe per IP for a provider, then worst among those IPs. */
|
||||
/** Any up → up; else degraded, then down, then unknown. */
|
||||
export function bestAliveHealthStatus(
|
||||
statuses: readonly HealthLogStatus[],
|
||||
): HealthLogStatus {
|
||||
if (statuses.length === 0) return 'unknown'
|
||||
if (statuses.some((status) => status === 'up')) return 'up'
|
||||
if (statuses.some((status) => status === 'degraded')) return 'degraded'
|
||||
if (statuses.some((status) => status === 'down')) return 'down'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** Latest probe per IP for a provider, then any-up among those IPs. */
|
||||
export function providerHealthStatuses(
|
||||
items: readonly HealthLogProbe[],
|
||||
providers: readonly HealthCheckProvider[],
|
||||
@@ -120,8 +131,85 @@ export function providerHealthStatuses(
|
||||
const statuses = [...latestByIp.values()]
|
||||
.filter((item) => item.provider === provider)
|
||||
.map((item) => item.status)
|
||||
result[provider] = worstHealthStatus(statuses)
|
||||
result[provider] = bestAliveHealthStatus(statuses)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface IpDisplayHealth {
|
||||
status: HealthLogStatus
|
||||
latency_ms: number | null
|
||||
last_checked_at: string
|
||||
last_error: string | null
|
||||
colo: string | null
|
||||
provider: HealthCheckProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest probe per provider+IP, then any-up among those providers.
|
||||
* Used by the IP table so hysteresis `unknown` in ip_health does not hide a live OK.
|
||||
*/
|
||||
export function latestHealthByIp(
|
||||
items: readonly HealthLogProbe[],
|
||||
): Map<string, IpDisplayHealth> {
|
||||
const latest = new Map<string, HealthLogProbe>()
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||
)
|
||||
for (const item of sorted) {
|
||||
const key = `${item.provider}\0${item.ip}`
|
||||
if (!latest.has(key)) latest.set(key, item)
|
||||
}
|
||||
|
||||
const byIp = new Map<string, HealthLogProbe[]>()
|
||||
for (const item of latest.values()) {
|
||||
const list = byIp.get(item.ip)
|
||||
if (list) list.push(item)
|
||||
else byIp.set(item.ip, [item])
|
||||
}
|
||||
|
||||
const result = new Map<string, IpDisplayHealth>()
|
||||
for (const [ip, probes] of byIp) {
|
||||
const status = bestAliveHealthStatus(probes.map((probe) => probe.status))
|
||||
const preferred =
|
||||
probes.find((probe) => probe.status === status) ?? probes[0]!
|
||||
result.set(ip, {
|
||||
status,
|
||||
latency_ms: preferred.latency_ms,
|
||||
last_checked_at: preferred.checked_at,
|
||||
last_error: preferred.error,
|
||||
colo: preferred.colo,
|
||||
provider: preferred.provider,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Prefer a concrete live probe over stored hysteresis `unknown`. */
|
||||
export function resolveIpDisplayHealth(
|
||||
stored: HealthLogStatus | undefined,
|
||||
live: HealthLogStatus | undefined,
|
||||
): HealthLogStatus {
|
||||
if (live && live !== 'unknown') return live
|
||||
return stored ?? live ?? 'unknown'
|
||||
}
|
||||
|
||||
/** Service KPI / card: any-up of per-IP overlay (live probes beat hysteresis). */
|
||||
export function resolveServiceDisplayHealth(
|
||||
stored: HealthLogStatus | undefined,
|
||||
ipHealth: readonly { ip: string; status: string }[],
|
||||
probes: readonly HealthLogProbe[] = [],
|
||||
): HealthLogStatus {
|
||||
const liveByIp = latestHealthByIp(probes)
|
||||
const statuses =
|
||||
ipHealth.length > 0
|
||||
? ipHealth.map((row) =>
|
||||
resolveIpDisplayHealth(
|
||||
row.status as HealthLogStatus,
|
||||
liveByIp.get(row.ip)?.status,
|
||||
),
|
||||
)
|
||||
: [...liveByIp.values()].map((row) => row.status)
|
||||
return resolveIpDisplayHealth(stored, bestAliveHealthStatus(statuses))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { bindLiveRefresh, isLiveRefresh, setLiveRefresh } from './live-refresh'
|
||||
|
||||
describe('setLiveRefresh', () => {
|
||||
afterEach(() => {
|
||||
if (isLiveRefresh()) return
|
||||
const client = new QueryClient()
|
||||
setLiveRefresh(client, true)
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('гасит интервал и возвращает его, даже если наблюдатель ставит свой снова', () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
const unbind = bindLiveRefresh(client)
|
||||
const observer = new QueryObserver(client, {
|
||||
queryKey: ['live-refresh'],
|
||||
queryFn: async () => 'ok',
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
const unsubscribe = observer.subscribe(() => {})
|
||||
|
||||
setLiveRefresh(client, false)
|
||||
expect(observer.options.refetchInterval).toBe(false)
|
||||
|
||||
observer.setOptions({
|
||||
queryKey: ['live-refresh'],
|
||||
queryFn: async () => 'ok',
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
expect(observer.options.refetchInterval).toBe(false)
|
||||
|
||||
setLiveRefresh(client, true)
|
||||
expect(observer.options.refetchInterval).toBe(10_000)
|
||||
|
||||
unsubscribe()
|
||||
unbind()
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
const STORAGE_KEY = 'cfdm.live-refresh'
|
||||
|
||||
type RefetchInterval = number | false | undefined | ((query: unknown) => number | false | undefined)
|
||||
|
||||
type IntervalObserver = {
|
||||
options: { refetchInterval?: RefetchInterval }
|
||||
setOptions: (options: IntervalObserver['options']) => void
|
||||
}
|
||||
|
||||
const intendedInterval = new WeakMap<IntervalObserver, RefetchInterval>()
|
||||
|
||||
function readStoredLiveRefresh() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) !== '0'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let liveEnabled = readStoredLiveRefresh()
|
||||
|
||||
export function isLiveRefresh() {
|
||||
return liveEnabled
|
||||
}
|
||||
|
||||
function asObserver(observer: object): IntervalObserver {
|
||||
return observer as IntervalObserver
|
||||
}
|
||||
|
||||
function remember(observer: IntervalObserver) {
|
||||
if (!intendedInterval.has(observer)) {
|
||||
intendedInterval.set(observer, observer.options.refetchInterval)
|
||||
}
|
||||
}
|
||||
|
||||
function syncObserver(observer: IntervalObserver, live: boolean) {
|
||||
remember(observer)
|
||||
const next = live ? intendedInterval.get(observer) : false
|
||||
if (Object.is(observer.options.refetchInterval, next)) return
|
||||
observer.setOptions({ ...observer.options, refetchInterval: next })
|
||||
}
|
||||
|
||||
function applyLiveRefresh(queryClient: QueryClient, live: boolean) {
|
||||
for (const query of queryClient.getQueryCache().getAll()) {
|
||||
for (const observer of query.observers) {
|
||||
syncObserver(asObserver(observer), live)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Пауза и возобновление уже заданных refetchInterval. Новые запросы без интервала не запускают опрос. */
|
||||
export function setLiveRefresh(queryClient: QueryClient, enabled: boolean) {
|
||||
liveEnabled = enabled
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
|
||||
} catch {
|
||||
// приватный режим без хранилища — переключатель живёт до перезагрузки
|
||||
}
|
||||
applyLiveRefresh(queryClient, enabled)
|
||||
}
|
||||
|
||||
export function bindLiveRefresh(queryClient: QueryClient) {
|
||||
applyLiveRefresh(queryClient, liveEnabled)
|
||||
return queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type !== 'observerAdded' && event.type !== 'observerOptionsUpdated') return
|
||||
syncObserver(asObserver(event.observer), liveEnabled)
|
||||
})
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
active_ips: z.array(z.string()).default([]),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
...binding,
|
||||
@@ -125,6 +126,18 @@ export const healthProbeLogSchema = z.object({
|
||||
checked_at: z.string(),
|
||||
})
|
||||
|
||||
export const failoverLogSchema = z.object({
|
||||
id: z.number(),
|
||||
service_id: z.number(),
|
||||
binding_id: z.number(),
|
||||
fqdn: z.string(),
|
||||
ip: z.string(),
|
||||
action: z.enum(['added', 'removed']),
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
export type FailoverLogEntry = z.infer<typeof failoverLogSchema>
|
||||
|
||||
export const serviceViewSchema = serviceSchema.extend({
|
||||
subdomain: z.string().default(''),
|
||||
enabled: z.coerce.boolean().default(false),
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
DEFAULT_BINDING_HEALTH,
|
||||
addAddressNode,
|
||||
addCommonFqdn,
|
||||
addExtraFqdn,
|
||||
emptyAddressBlock,
|
||||
emptyBindingDraft,
|
||||
hydrateAddressBlock,
|
||||
patchAddressIpMeta,
|
||||
removeAddressNode,
|
||||
toAddressBindings,
|
||||
toDomainsPayload,
|
||||
@@ -44,8 +46,8 @@ describe('hydrateAddressBlock', () => {
|
||||
|
||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
|
||||
{ ip: '185.244.181.61', extraFqdn: '' },
|
||||
{ ip: '93.115.203.183', extraFqdns: ['msk.rutg.rkns.top'] },
|
||||
{ ip: '185.244.181.61', extraFqdns: [] },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
@@ -65,7 +67,7 @@ describe('hydrateAddressBlock', () => {
|
||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
|
||||
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
|
||||
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
|
||||
})
|
||||
|
||||
@@ -77,11 +79,105 @@ describe('hydrateAddressBlock', () => {
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
|
||||
|
||||
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
|
||||
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdns: [] }])
|
||||
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||
expect(state.preservedBindings).toHaveLength(1)
|
||||
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
||||
})
|
||||
|
||||
it('при одном IP пула отделяет второй A в extraFqdn узла', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('кладёт несколько extra A на один IP в extraFqdns, включая wildcard', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{
|
||||
ip: '130.49.213.176',
|
||||
extraFqdns: ['ndns.shnt.top', '*.mdns.shnt.top'],
|
||||
},
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('поднимает веса и приоритеты с общего FQDN', () => {
|
||||
const drafts = [
|
||||
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
|
||||
target_ip_weights: { '130.49.213.153': 3, '93.115.203.183': 1 },
|
||||
target_ip_priorities: { '130.49.213.153': 2, '93.115.203.183': 1 },
|
||||
}),
|
||||
aRecord('nsgt.rkns.top', ['130.49.213.153']),
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, ['130.49.213.153', '93.115.203.183'])
|
||||
expect(state.target_ip_weights).toEqual({
|
||||
'130.49.213.153': 3,
|
||||
'93.115.203.183': 1,
|
||||
})
|
||||
expect(state.target_ip_priorities).toEqual({
|
||||
'130.49.213.153': 2,
|
||||
'93.115.203.183': 1,
|
||||
})
|
||||
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
|
||||
})
|
||||
|
||||
it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => {
|
||||
const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183']
|
||||
const drafts = [
|
||||
// corrupted common — missing one pool IP
|
||||
aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']),
|
||||
aRecord('gt.rkns.top', pool),
|
||||
aRecord('nsgt.rkns.top', ['130.49.213.176']),
|
||||
aRecord('rutg.rkns.top', ['93.115.203.183']),
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, pool)
|
||||
|
||||
expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '130.49.213.153', extraFqdns: [] },
|
||||
{ ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] },
|
||||
{ ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
|
||||
const payload = toDomainsPayload(state, primaryMeta)
|
||||
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||
'gw.pngs.top',
|
||||
'gt.rkns.top',
|
||||
'nsgt.rkns.top',
|
||||
'rutg.rkns.top',
|
||||
])
|
||||
expect(payload[0]?.target_ips).toEqual(pool)
|
||||
expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4)
|
||||
})
|
||||
|
||||
it('не дублирует FQDN при повторном binding в drafts', () => {
|
||||
const pool = ['1.1.1.1', '2.2.2.2']
|
||||
const state = hydrateAddressBlock(
|
||||
[aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)],
|
||||
pool,
|
||||
)
|
||||
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toDomainsPayload', () => {
|
||||
@@ -122,6 +218,40 @@ describe('toDomainsPayload', () => {
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('круг hydrate → payload → hydrate сохраняет extra FQDN при одном IP', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
expect(first.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(first.nodes).toEqual([
|
||||
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
|
||||
])
|
||||
const rebound = toAddressBindings(first, primaryMeta)
|
||||
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
|
||||
|
||||
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('круг hydrate → payload → hydrate сохраняет несколько extra и wildcard при одном IP', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
expect(first.nodes[0]?.extraFqdns).toEqual(['ndns.shnt.top', '*.mdns.shnt.top'])
|
||||
const rebound = toAddressBindings(first, primaryMeta)
|
||||
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
|
||||
|
||||
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAddressNode', () => {
|
||||
@@ -137,7 +267,7 @@ describe('removeAddressNode', () => {
|
||||
|
||||
const next = removeAddressNode(state, '10.0.0.1')
|
||||
|
||||
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
|
||||
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdns: [] }])
|
||||
expect(next.preservedBindings).toHaveLength(1)
|
||||
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
|
||||
})
|
||||
@@ -146,7 +276,7 @@ describe('removeAddressNode', () => {
|
||||
describe('addAddressNode / addCommonFqdn', () => {
|
||||
it('не добавляет дубликат IP', () => {
|
||||
const withIp = addAddressNode(
|
||||
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
|
||||
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdns: [] }] },
|
||||
'1.1.1.1',
|
||||
)
|
||||
expect(withIp.nodes).toHaveLength(1)
|
||||
@@ -159,6 +289,42 @@ describe('addAddressNode / addCommonFqdn', () => {
|
||||
)
|
||||
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
|
||||
})
|
||||
|
||||
it('добавляет extra FQDN к IP и отклоняет дубликат', () => {
|
||||
const withIp = addAddressNode(emptyAddressBlock(), '1.1.1.1')
|
||||
const withExtra = addExtraFqdn(withIp, '1.1.1.1', 'mdns.shnt.top')
|
||||
expect(withExtra.nodes[0]?.extraFqdns).toEqual(['mdns.shnt.top'])
|
||||
expect(addExtraFqdn(withExtra, '1.1.1.1', 'MDNS.shnt.top')).toBe(withExtra)
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchAddressIpMeta', () => {
|
||||
it('меняет вес одного IP и не трогает extraFqdns', () => {
|
||||
const state = {
|
||||
...addAddressNode(addAddressNode(emptyAddressBlock(), '1.1.1.1'), '2.2.2.2'),
|
||||
nodes: [
|
||||
{ ip: '1.1.1.1', extraFqdns: ['msk.example.com'] },
|
||||
{ ip: '2.2.2.2', extraFqdns: [] },
|
||||
],
|
||||
}
|
||||
const next = patchAddressIpMeta(state, '1.1.1.1', { weight: 7 })
|
||||
expect(next.target_ip_weights).toEqual({ '1.1.1.1': 7, '2.2.2.2': 1 })
|
||||
expect(next.target_ip_priorities).toEqual(state.target_ip_priorities)
|
||||
expect(next.nodes).toEqual(state.nodes)
|
||||
})
|
||||
|
||||
it('clamp веса и приоритета в 1–100', () => {
|
||||
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
|
||||
expect(patchAddressIpMeta(state, '10.0.0.1', { weight: 0 }).target_ip_weights['10.0.0.1']).toBe(1)
|
||||
expect(
|
||||
patchAddressIpMeta(state, '10.0.0.1', { priority: 999 }).target_ip_priorities['10.0.0.1'],
|
||||
).toBe(100)
|
||||
})
|
||||
|
||||
it('игнорирует IP вне пула', () => {
|
||||
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
|
||||
expect(patchAddressIpMeta(state, '8.8.8.8', { weight: 5 })).toBe(state)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CNAME / preservedBindings', () => {
|
||||
@@ -174,7 +340,7 @@ describe('CNAME / preservedBindings', () => {
|
||||
cname,
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
|
||||
expect(state.nodes[0]?.extraFqdns).toEqual(['msk.rkns.top'])
|
||||
expect(state.preservedBindings).toHaveLength(1)
|
||||
|
||||
const payload = toDomainsPayload(state, primaryMeta)
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ServiceBindingDraft {
|
||||
|
||||
export interface AddressNode {
|
||||
ip: string
|
||||
extraFqdn: string
|
||||
extraFqdns: string[]
|
||||
}
|
||||
|
||||
export interface AddressBlockState {
|
||||
@@ -113,6 +113,10 @@ function sameIpSet(left: string[], right: string[]): boolean {
|
||||
return right.every((ip) => set.has(ip.trim()))
|
||||
}
|
||||
|
||||
function fqdnKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
@@ -145,6 +149,36 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
|
||||
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
||||
}
|
||||
|
||||
/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */
|
||||
function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set<string>): boolean {
|
||||
if (draft.record_type !== 'A') return false
|
||||
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
|
||||
if (ips.length < 2) return false
|
||||
return ips.every((ip) => poolSet.has(ip))
|
||||
}
|
||||
|
||||
function takeAsCommon(
|
||||
draft: ServiceBindingDraft,
|
||||
fqdn: string,
|
||||
commonFqdns: string[],
|
||||
seenCommon: Set<string>,
|
||||
weights: Record<string, number>,
|
||||
priorities: Record<string, number>,
|
||||
): { weights: Record<string, number>; priorities: Record<string, number> } {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (fqdn && key && !seenCommon.has(key)) {
|
||||
seenCommon.add(key)
|
||||
commonFqdns.push(draft.fqdn)
|
||||
}
|
||||
if (Object.keys(weights).length === 0) {
|
||||
return {
|
||||
weights: { ...draft.target_ip_weights },
|
||||
priorities: { ...draft.target_ip_priorities },
|
||||
}
|
||||
}
|
||||
return { weights, priorities }
|
||||
}
|
||||
|
||||
export function hydrateAddressBlock(
|
||||
drafts: ServiceBindingDraft[],
|
||||
pool: string[] = [],
|
||||
@@ -161,27 +195,73 @@ export function hydrateAddressBlock(
|
||||
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
||||
const poolSet = new Set(ips)
|
||||
const commonFqdns: string[] = []
|
||||
const claimed = new Set<string>()
|
||||
const extraByIp = new Map<string, string>()
|
||||
const seenCommon = new Set<string>()
|
||||
const seenExtra = new Set<string>()
|
||||
const extraByIp = new Map<string, string[]>()
|
||||
const preservedBindings: ServiceBindingDraft[] = []
|
||||
let weights: Record<string, number> = {}
|
||||
let priorities: Record<string, number> = {}
|
||||
const splitSinglePool =
|
||||
ips.length === 1 &&
|
||||
drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))
|
||||
.length > 1
|
||||
let assignedFirstSinglePoolCommon = false
|
||||
|
||||
function pushExtra(ip: string, fqdn: string) {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (!key || seenExtra.has(key) || seenCommon.has(key)) return
|
||||
seenExtra.add(key)
|
||||
const list = extraByIp.get(ip) ?? []
|
||||
list.push(fqdn)
|
||||
extraByIp.set(ip, list)
|
||||
}
|
||||
|
||||
function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (key && seenExtra.has(key)) {
|
||||
seenExtra.delete(key)
|
||||
for (const [ip, list] of extraByIp) {
|
||||
extraByIp.set(
|
||||
ip,
|
||||
list.filter((item) => fqdnKey(item) !== key),
|
||||
)
|
||||
}
|
||||
}
|
||||
const next = takeAsCommon(
|
||||
draft,
|
||||
fqdn,
|
||||
commonFqdns,
|
||||
seenCommon,
|
||||
weights,
|
||||
priorities,
|
||||
)
|
||||
weights = next.weights
|
||||
priorities = next.priorities
|
||||
}
|
||||
|
||||
for (const draft of drafts) {
|
||||
const fqdn = draft.fqdn.trim()
|
||||
if (isFullPoolA(draft, ips)) {
|
||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
||||
if (Object.keys(weights).length === 0) {
|
||||
weights = { ...draft.target_ip_weights }
|
||||
priorities = { ...draft.target_ip_priorities }
|
||||
if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) {
|
||||
if (!assignedFirstSinglePoolCommon) {
|
||||
assignedFirstSinglePoolCommon = true
|
||||
promoteToCommon(draft, fqdn)
|
||||
continue
|
||||
}
|
||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||
if (ip && poolSet.has(ip) && fqdn) {
|
||||
pushExtra(ip, draft.fqdn)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage).
|
||||
if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) {
|
||||
promoteToCommon(draft, fqdn)
|
||||
continue
|
||||
}
|
||||
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||
if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) {
|
||||
claimed.add(ip)
|
||||
extraByIp.set(ip, draft.fqdn)
|
||||
if (ip && poolSet.has(ip) && fqdn) {
|
||||
pushExtra(ip, draft.fqdn)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -192,7 +272,7 @@ export function hydrateAddressBlock(
|
||||
commonFqdns,
|
||||
nodes: ips.map((ip) => ({
|
||||
ip,
|
||||
extraFqdn: extraByIp.get(ip) ?? '',
|
||||
extraFqdns: extraByIp.get(ip) ?? [],
|
||||
})),
|
||||
preservedBindings,
|
||||
target_ip_weights: weights,
|
||||
@@ -237,28 +317,66 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
|
||||
nodes: [...state.nodes, { ip: trimmed, extraFqdns: [] }],
|
||||
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
|
||||
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function fqdnKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
const LB_META_MIN = 1
|
||||
const LB_META_MAX = 100
|
||||
|
||||
function clampLbMeta(value: number): number {
|
||||
if (!Number.isFinite(value)) return LB_META_MIN
|
||||
return Math.min(LB_META_MAX, Math.max(LB_META_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
export function patchAddressIpMeta(
|
||||
state: AddressBlockState,
|
||||
ip: string,
|
||||
meta: { weight?: number; priority?: number },
|
||||
): AddressBlockState {
|
||||
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||
return {
|
||||
...state,
|
||||
target_ip_weights:
|
||||
meta.weight === undefined
|
||||
? state.target_ip_weights
|
||||
: { ...state.target_ip_weights, [ip]: clampLbMeta(meta.weight) },
|
||||
target_ip_priorities:
|
||||
meta.priority === undefined
|
||||
? state.target_ip_priorities
|
||||
: { ...state.target_ip_priorities, [ip]: clampLbMeta(meta.priority) },
|
||||
}
|
||||
}
|
||||
|
||||
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (!key) return false
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
|
||||
if (state.nodes.some((node) => fqdnKey(node.extraFqdn) === key)) return true
|
||||
return false
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return true
|
||||
}
|
||||
return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key)
|
||||
}
|
||||
|
||||
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
||||
const trimmed = fqdn.trim()
|
||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
||||
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
|
||||
if (!trimmed) return state
|
||||
const key = fqdnKey(trimmed)
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return state
|
||||
}
|
||||
// Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates).
|
||||
const preservedBindings = state.preservedBindings.filter(
|
||||
(item) => fqdnKey(item.fqdn) !== key,
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
commonFqdns: [...state.commonFqdns, trimmed],
|
||||
preservedBindings,
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
||||
@@ -279,6 +397,62 @@ export function updateCommonFqdn(
|
||||
}
|
||||
}
|
||||
|
||||
export function addExtraFqdn(
|
||||
state: AddressBlockState,
|
||||
ip: string,
|
||||
fqdn: string,
|
||||
): AddressBlockState {
|
||||
const trimmed = fqdn.trim()
|
||||
if (!trimmed) return state
|
||||
const key = fqdnKey(trimmed)
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return state
|
||||
}
|
||||
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||
return {
|
||||
...state,
|
||||
preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key),
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function removeExtraFqdn(
|
||||
state: AddressBlockState,
|
||||
ip: string,
|
||||
index: number,
|
||||
): AddressBlockState {
|
||||
return {
|
||||
...state,
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.ip === ip
|
||||
? { ...node, extraFqdns: node.extraFqdns.filter((_, i) => i !== index) }
|
||||
: node,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateExtraFqdn(
|
||||
state: AddressBlockState,
|
||||
ip: string,
|
||||
index: number,
|
||||
fqdn: string,
|
||||
): AddressBlockState {
|
||||
return {
|
||||
...state,
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.ip === ip
|
||||
? {
|
||||
...node,
|
||||
extraFqdns: node.extraFqdns.map((item, i) => (i === index ? fqdn : item)),
|
||||
}
|
||||
: node,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAddressBindings(
|
||||
state: AddressBlockState,
|
||||
primary: AddressPrimaryMeta,
|
||||
@@ -308,21 +482,29 @@ export function toAddressBindings(
|
||||
}
|
||||
|
||||
for (const node of state.nodes) {
|
||||
const extraFqdn = node.extraFqdn.trim()
|
||||
if (!extraFqdn) continue
|
||||
drafts.push({
|
||||
fqdn: extraFqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [node.ip],
|
||||
target_cname: '',
|
||||
lb_mode: primary.lb_mode,
|
||||
health: { ...primary.health },
|
||||
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||
})
|
||||
for (const raw of node.extraFqdns) {
|
||||
const extraFqdn = raw.trim()
|
||||
if (!extraFqdn) continue
|
||||
drafts.push({
|
||||
fqdn: extraFqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [node.ip],
|
||||
target_cname: '',
|
||||
lb_mode: primary.lb_mode,
|
||||
health: { ...primary.health },
|
||||
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
drafts.push(...state.preservedBindings)
|
||||
const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean))
|
||||
for (const preserved of state.preservedBindings) {
|
||||
const key = fqdnKey(preserved.fqdn)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
drafts.push(preserved)
|
||||
}
|
||||
return drafts
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { deriveSystemAttention, isCertExpiringWithin } from './system-attention'
|
||||
|
||||
const NOW = Date.parse('2026-11-02T00:00:00.000Z')
|
||||
|
||||
function inDays(days: number) {
|
||||
return new Date(NOW + days * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
describe('deriveSystemAttention', () => {
|
||||
it('считает Down и деградацию в группах и без группы', () => {
|
||||
const attention = deriveSystemAttention({
|
||||
now: NOW,
|
||||
readyIsSuccess: true,
|
||||
readyIsError: false,
|
||||
readyStatus: 'ready',
|
||||
services: {
|
||||
groups: [
|
||||
{
|
||||
services: [
|
||||
{ id: 1, name: 'Альфа', health_status: 'down' },
|
||||
{ id: 2, name: 'Бета', health_status: 'up' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ id: 3, name: 'Гамма', health_status: 'degraded' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(attention.servicesDown).toBe(1)
|
||||
expect(attention.servicesDegraded).toBe(1)
|
||||
expect(attention.incidents.map((item) => item.title)).toEqual(['Альфа', 'Гамма'])
|
||||
expect(attention.incidents[0]).toMatchObject({
|
||||
tone: 'destructive',
|
||||
href: { to: '/services/$serviceId', serviceId: '1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('берёт сертификаты с остатком не больше 14 дней, включая истёкшие', () => {
|
||||
expect(isCertExpiringWithin(inDays(14), 14, NOW)).toBe(true)
|
||||
expect(isCertExpiringWithin(inDays(14.01), 14, NOW)).toBe(false)
|
||||
expect(isCertExpiringWithin(inDays(-1), 14, NOW)).toBe(true)
|
||||
|
||||
const attention = deriveSystemAttention({
|
||||
now: NOW,
|
||||
readyIsSuccess: false,
|
||||
readyIsError: false,
|
||||
certificates: [
|
||||
{ id: 10, hostname: 'soon.example', expires_at: inDays(3), service_id: 7 },
|
||||
{ id: 11, hostname: 'later.example', expires_at: inDays(20), service_id: 7 },
|
||||
{ id: 12, hostname: 'dead.example', expires_at: inDays(-2), service_id: null },
|
||||
{ id: 13, hostname: 'unknown.example', expires_at: null },
|
||||
],
|
||||
certSummary: [
|
||||
['warning', 2],
|
||||
['expired', 1],
|
||||
['ok', 4],
|
||||
],
|
||||
notifications: [
|
||||
{ kind: 'sync', title: 'Sync failed', message: 'timeout' },
|
||||
{ kind: 'info', title: 'Готово', message: 'ok' },
|
||||
],
|
||||
domains: [{ group_id: null }, { group_id: 1 }],
|
||||
})
|
||||
|
||||
expect(attention.certsExpiring).toBe(2)
|
||||
expect(attention.certWarnings).toBe(3)
|
||||
expect(attention.failedJobs).toBe(1)
|
||||
expect(attention.ungroupedCount).toBe(1)
|
||||
expect(attention.incidents.map((item) => item.title)).toEqual([
|
||||
'dead.example',
|
||||
'soon.example',
|
||||
])
|
||||
expect(attention.incidents[0]?.href).toEqual({ to: '/certificates' })
|
||||
expect(attention.incidents[1]?.href).toEqual({
|
||||
to: '/services/$serviceId',
|
||||
serviceId: '7',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
/** Общее окно «истекает» для точек сайдбара и инцидентов монитора. */
|
||||
export const CERT_EXPIRING_WITHIN_DAYS = 14
|
||||
|
||||
const MS_PER_DAY = 86_400_000
|
||||
|
||||
export type AttentionService = {
|
||||
id: number
|
||||
name: string
|
||||
health_status: string
|
||||
}
|
||||
|
||||
export type AttentionServiceGroups = {
|
||||
groups: { services: AttentionService[] }[]
|
||||
ungrouped: AttentionService[]
|
||||
}
|
||||
|
||||
export type AttentionCertificate = {
|
||||
id: number
|
||||
hostname: string
|
||||
expires_at: string | null
|
||||
service_id?: number | null
|
||||
}
|
||||
|
||||
export type AttentionNotification = {
|
||||
kind: string
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AttentionDomain = {
|
||||
group_id: number | null
|
||||
}
|
||||
|
||||
export type SystemIncident = {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
tone: 'destructive' | 'warning'
|
||||
href:
|
||||
| { to: '/services/$serviceId'; serviceId: string }
|
||||
| { to: '/certificates' }
|
||||
}
|
||||
|
||||
export type SystemAttention = {
|
||||
servicesDown: number
|
||||
servicesDegraded: number
|
||||
certWarnings: number
|
||||
certsExpiring: number
|
||||
failedJobs: number
|
||||
ungroupedCount: number
|
||||
readyOk: boolean
|
||||
readyDegraded: boolean
|
||||
incidents: SystemIncident[]
|
||||
}
|
||||
|
||||
type DeriveInput = {
|
||||
services?: AttentionServiceGroups
|
||||
certificates?: AttentionCertificate[]
|
||||
certSummary?: [string, number][]
|
||||
notifications?: AttentionNotification[]
|
||||
domains?: AttentionDomain[]
|
||||
readyStatus?: string
|
||||
readyIsSuccess: boolean
|
||||
readyIsError: boolean
|
||||
now?: number
|
||||
}
|
||||
|
||||
function countByStatus(
|
||||
summary: [string, number][] | undefined,
|
||||
statuses: string[],
|
||||
) {
|
||||
if (!summary) return 0
|
||||
return summary
|
||||
.filter(([status]) => statuses.includes(status))
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
}
|
||||
|
||||
function daysUntilExpiry(expiresAt: string, now: number): number | null {
|
||||
const ts = new Date(expiresAt).getTime()
|
||||
if (Number.isNaN(ts)) return null
|
||||
return (ts - now) / MS_PER_DAY
|
||||
}
|
||||
|
||||
/** Истёкшие тоже входят: дней до срока ≤ 14, включая отрицательные. */
|
||||
export function isCertExpiringWithin(
|
||||
expiresAt: string | null,
|
||||
withinDays: number,
|
||||
now: number,
|
||||
) {
|
||||
if (!expiresAt) return false
|
||||
const days = daysUntilExpiry(expiresAt, now)
|
||||
return days != null && days <= withinDays
|
||||
}
|
||||
|
||||
function eachService(
|
||||
data: AttentionServiceGroups | undefined,
|
||||
visit: (service: AttentionService) => void,
|
||||
) {
|
||||
if (!data) return
|
||||
for (const group of data.groups) {
|
||||
for (const service of group.services) visit(service)
|
||||
}
|
||||
for (const service of data.ungrouped) visit(service)
|
||||
}
|
||||
|
||||
function certDetail(days: number) {
|
||||
if (days < 0) return 'Истёк'
|
||||
if (days < 1) return 'Сегодня'
|
||||
return `${Math.ceil(days)} дн.`
|
||||
}
|
||||
|
||||
function compareRu(a: string, b: string) {
|
||||
return a.localeCompare(b, 'ru')
|
||||
}
|
||||
|
||||
export function deriveSystemAttention(input: DeriveInput): SystemAttention {
|
||||
const now = input.now ?? Date.now()
|
||||
let servicesDown = 0
|
||||
let servicesDegraded = 0
|
||||
const serviceIncidents: SystemIncident[] = []
|
||||
|
||||
eachService(input.services, (service) => {
|
||||
if (service.health_status === 'down') {
|
||||
servicesDown += 1
|
||||
serviceIncidents.push({
|
||||
id: `service:${service.id}`,
|
||||
title: service.name || `Сервис ${service.id}`,
|
||||
detail: 'Недоступен',
|
||||
tone: 'destructive',
|
||||
href: { to: '/services/$serviceId', serviceId: String(service.id) },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (service.health_status === 'degraded') {
|
||||
servicesDegraded += 1
|
||||
serviceIncidents.push({
|
||||
id: `service:${service.id}`,
|
||||
title: service.name || `Сервис ${service.id}`,
|
||||
detail: 'Деградация',
|
||||
tone: 'warning',
|
||||
href: { to: '/services/$serviceId', serviceId: String(service.id) },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
serviceIncidents.sort((a, b) => {
|
||||
if (a.tone !== b.tone) return a.tone === 'destructive' ? -1 : 1
|
||||
return compareRu(a.title, b.title)
|
||||
})
|
||||
|
||||
const certIncidents: { incident: SystemIncident; days: number }[] = []
|
||||
for (const cert of input.certificates ?? []) {
|
||||
if (!cert.expires_at) continue
|
||||
const days = daysUntilExpiry(cert.expires_at, now)
|
||||
if (days == null || days > CERT_EXPIRING_WITHIN_DAYS) continue
|
||||
const serviceId = cert.service_id
|
||||
certIncidents.push({
|
||||
days,
|
||||
incident: {
|
||||
id: `cert:${cert.id}`,
|
||||
title: cert.hostname,
|
||||
detail: certDetail(days),
|
||||
tone: days < 0 ? 'destructive' : 'warning',
|
||||
href:
|
||||
serviceId != null
|
||||
? { to: '/services/$serviceId', serviceId: String(serviceId) }
|
||||
: { to: '/certificates' },
|
||||
},
|
||||
})
|
||||
}
|
||||
certIncidents.sort((a, b) => a.days - b.days || compareRu(a.incident.title, b.incident.title))
|
||||
|
||||
const failedJobs = (input.notifications ?? []).filter((entry) =>
|
||||
/fail|error/i.test(`${entry.kind} ${entry.title} ${entry.message}`),
|
||||
).length
|
||||
|
||||
return {
|
||||
servicesDown,
|
||||
servicesDegraded,
|
||||
certWarnings: countByStatus(input.certSummary, ['warning', 'expired', 'error']),
|
||||
certsExpiring: certIncidents.length,
|
||||
failedJobs,
|
||||
ungroupedCount: (input.domains ?? []).filter((domain) => domain.group_id == null).length,
|
||||
readyOk: input.readyStatus === 'ready' && input.readyIsSuccess,
|
||||
readyDegraded: input.readyStatus === 'degraded' || input.readyIsError,
|
||||
incidents: [...serviceIncidents, ...certIncidents.map((item) => item.incident)],
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Toaster } from '@cfdm/ui/components/sonner'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { queryClient } from './lib/queryClient'
|
||||
import '@cfdm/ui/globals.css'
|
||||
import '@/styles/appearance-modes.css'
|
||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
||||
|
||||
debugAgentLog('main.tsx:boot', 'app boot', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { queryOptions, type QueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
failoverLogSchema,
|
||||
healthProbeLogSchema,
|
||||
serviceBindingSchema,
|
||||
serviceGroupsResponseSchema,
|
||||
@@ -35,6 +36,8 @@ export const serviceGroupsQueryOptions = () =>
|
||||
}
|
||||
return parsed.data
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const servicesQueryOptions = () =>
|
||||
@@ -89,6 +92,7 @@ export const serviceDetailKeys = {
|
||||
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
|
||||
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
|
||||
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
|
||||
failoverLog: (id: number) => [...serviceKeys.all, id, 'failover-log'] as const,
|
||||
view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
|
||||
}
|
||||
|
||||
@@ -99,6 +103,8 @@ export const serviceViewQueryOptions = (id: number) =>
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
||||
return serviceViewSchema.parse(data)
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
@@ -108,6 +114,17 @@ export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
||||
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const serviceFailoverLogQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceDetailKeys.failoverLog(id),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}/failover-log`)
|
||||
return z.object({ items: z.array(failoverLogSchema) }).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceOverviewQueryOptions = (id: number) =>
|
||||
@@ -193,3 +210,17 @@ export async function createOriginHealthCheck(body: Record<string, unknown>) {
|
||||
export async function listOriginHealthChecks() {
|
||||
return api.get('/api/v1/health-checks')
|
||||
}
|
||||
|
||||
export function invalidateServiceDetail(queryClient: QueryClient, id: number) {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
|
||||
])
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { CircleAlertIcon, SearchIcon, ShieldCheckIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { sqliteUtcToIso } from '@/lib/format'
|
||||
import type { Certificate } from '@/lib/schemas'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { IncidentAlert } from '@/components/incident-alert'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { CertKpiStats } from '@/components/cert-kpi-stats'
|
||||
import {
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
useCertFilterFields,
|
||||
} from '@/components/columns/certificates-columns'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/certificates')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -29,6 +34,7 @@ export const Route = createFileRoute('/_auth/certificates')({
|
||||
})
|
||||
|
||||
function CertificatesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultCertFilters)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const queryClient = useQueryClient()
|
||||
@@ -59,6 +65,7 @@ function CertificatesPage() {
|
||||
|
||||
const filterFields = useCertFilterFields()
|
||||
const columns = useCertificateColumns()
|
||||
const expiredCert = useMemo(() => pickHeaviestExpired(certs), [certs])
|
||||
|
||||
const primaryAction = (
|
||||
<LoadingButton
|
||||
@@ -66,7 +73,7 @@ function CertificatesPage() {
|
||||
isLoading={checkMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
>
|
||||
Запустить проверку
|
||||
Проверить все
|
||||
</LoadingButton>
|
||||
)
|
||||
|
||||
@@ -77,6 +84,13 @@ function CertificatesPage() {
|
||||
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||
actions={primaryAction}
|
||||
/>
|
||||
{expiredCert ? (
|
||||
<IncidentAlert
|
||||
title={expiredCertTitle(expiredCert)}
|
||||
description={expiredCertDescription(expiredCert)}
|
||||
onShow={() => showCertificate(expiredCert, navigate, setActiveTab)}
|
||||
/>
|
||||
) : null}
|
||||
<CertKpiStats
|
||||
summary={summary}
|
||||
total={certs?.length}
|
||||
@@ -104,12 +118,97 @@ function CertificatesPage() {
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
errorState={{
|
||||
icon: CircleAlertIcon,
|
||||
title: 'Не удалось загрузить сертификаты',
|
||||
description: error?.message ?? 'Повторите запрос.',
|
||||
action: (
|
||||
<Button type="button" variant="outline" onClick={() => refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
filteredEmptyState={{
|
||||
icon: SearchIcon,
|
||||
title: 'Нет совпадений',
|
||||
description: 'Измените поиск или сбросьте фильтры.',
|
||||
resetLabel: 'Сбросить фильтры',
|
||||
}}
|
||||
onFilteredEmptyReset={() => setActiveTab('all')}
|
||||
emptyState={{
|
||||
title: 'Сертификаты не найдены',
|
||||
description: 'Сертификаты появятся после проверки доменов',
|
||||
action: primaryAction,
|
||||
icon: ShieldCheckIcon,
|
||||
title: 'Нет данных',
|
||||
description: 'Сертификаты появятся после первой проверки сервисов.',
|
||||
action: (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending}
|
||||
>
|
||||
Проверить
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function pickHeaviestExpired(
|
||||
certs: Certificate[] | undefined,
|
||||
): Certificate | null {
|
||||
const expired = (certs ?? []).filter((cert) => cert.status === 'expired')
|
||||
if (expired.length === 0) return null
|
||||
return [...expired].sort((left, right) => {
|
||||
const delta = expiryMs(left.expires_at) - expiryMs(right.expires_at)
|
||||
if (delta !== 0) return delta
|
||||
return left.hostname.localeCompare(right.hostname, 'ru')
|
||||
})[0]
|
||||
}
|
||||
|
||||
function expiryMs(value: string | null): number {
|
||||
if (!value) return Number.POSITIVE_INFINITY
|
||||
const ms = new Date(sqliteUtcToIso(value) ?? value).getTime()
|
||||
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms
|
||||
}
|
||||
|
||||
function expiredCertTitle(cert: Certificate): string {
|
||||
const ms = expiryMs(cert.expires_at)
|
||||
if (!Number.isFinite(ms)) return `Сертификат ${cert.hostname} истёк.`
|
||||
const days = Math.floor((Date.now() - ms) / 86_400_000)
|
||||
if (days <= 0) return `Сертификат ${cert.hostname} истёк.`
|
||||
return `Сертификат ${cert.hostname} истёк ${days} ${pluralDays(days)} назад.`
|
||||
}
|
||||
|
||||
function expiredCertDescription(cert: Certificate): string | undefined {
|
||||
const parts = [
|
||||
cert.service_name ? `Сервис ${cert.service_name}` : null,
|
||||
cert.last_error,
|
||||
].filter((part): part is string => Boolean(part))
|
||||
if (parts.length === 0) return undefined
|
||||
return `${parts.join('. ')}.`
|
||||
}
|
||||
|
||||
function pluralDays(days: number): string {
|
||||
const mod10 = days % 10
|
||||
const mod100 = days % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return 'день'
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'дня'
|
||||
return 'дней'
|
||||
}
|
||||
|
||||
function showCertificate(
|
||||
cert: Certificate,
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
setActiveTab: (tabId: string) => void,
|
||||
): void {
|
||||
if (cert.service_id != null) {
|
||||
navigate({
|
||||
to: '/services/$serviceId',
|
||||
params: { serviceId: String(cert.service_id) },
|
||||
})
|
||||
return
|
||||
}
|
||||
setActiveTab('expired')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
@@ -10,7 +10,8 @@ import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, domainServiceBindingsQueryOptions, subdomainKeys } from '@/queries'
|
||||
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome'
|
||||
import {
|
||||
DNS_TABS,
|
||||
createDefaultDnsFilters,
|
||||
@@ -154,77 +155,63 @@ function DnsPage() {
|
||||
return list.filter((r) => r.name.toLowerCase().includes(query))
|
||||
}, [records, host])
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать с Cloudflare
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||
description={
|
||||
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
|
||||
}
|
||||
actions={headerActions}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/domains/$domainId" params={{ domainId }} />
|
||||
}
|
||||
>
|
||||
← Обзор домена
|
||||
</Button>
|
||||
</DetailPanel.Header>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<ResourcePage
|
||||
title="DNS-записи"
|
||||
description={domain?.zone_name ?? ''}
|
||||
hideHeader
|
||||
tabs={DNS_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={dnsTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultDnsFilters())}
|
||||
getFilterFieldValue={dnsFilterFieldValue}
|
||||
columns={columns}
|
||||
data={displayRecords}
|
||||
getRowId={(row) => String(row.id)}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchDomain()
|
||||
void refetchRecords()
|
||||
}}
|
||||
emptyState={{
|
||||
title: 'DNS-записи не найдены',
|
||||
description: 'Синхронизируйте зону или создайте запись вручную',
|
||||
action: (
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
<DomainDetailChrome domainId={domainId} activeTab="dns">
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{host ? (
|
||||
<p className="text-muted-foreground text-sm">Фильтр по хосту: {host}</p>
|
||||
) : null}
|
||||
<ResourcePage
|
||||
title="DNS-записи"
|
||||
description={domain?.zone_name ?? ''}
|
||||
hideHeader
|
||||
tabs={DNS_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={dnsTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultDnsFilters())}
|
||||
getFilterFieldValue={dnsFilterFieldValue}
|
||||
columns={columns}
|
||||
data={displayRecords}
|
||||
getRowId={(row) => String(row.id)}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchDomain()
|
||||
void refetchRecords()
|
||||
}}
|
||||
toolbarExtra={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать с Cloudflare
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'DNS-записи не найдены',
|
||||
description: 'Синхронизируйте зону или создайте запись вручную',
|
||||
action: (
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DomainDetailChrome>
|
||||
|
||||
<FormSheet
|
||||
open={sheetOpen}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
Link2Icon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
domainDetailQueryOptions,
|
||||
domainServiceBindingsQueryOptions,
|
||||
healthStatusKeys,
|
||||
runHealthCheck,
|
||||
serviceGroupsQueryOptions,
|
||||
servicesQueryOptions,
|
||||
subdomainsListQueryOptions,
|
||||
@@ -21,9 +11,7 @@ import {
|
||||
import { useDomainPage } from '@/hooks/use-domain-page'
|
||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
SUBDOMAIN_TABS,
|
||||
createDefaultSubdomainFilters,
|
||||
@@ -38,15 +26,23 @@ import {
|
||||
} from '@/components/subdomain-edit-sheet'
|
||||
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
||||
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome'
|
||||
import { type DomainSectionTab } from '@/components/domains/domain-detail-tabs'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
validateSearch: (search: Record<string, unknown>): { tab?: DomainSectionTab } => {
|
||||
const tab = search.tab
|
||||
if (
|
||||
tab === 'availability' ||
|
||||
tab === 'subdomains' ||
|
||||
tab === 'bindings' ||
|
||||
tab === 'overview'
|
||||
) {
|
||||
return { tab }
|
||||
}
|
||||
return {}
|
||||
},
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.domainId)
|
||||
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
|
||||
@@ -61,70 +57,28 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
component: DomainOverviewPage,
|
||||
})
|
||||
|
||||
function DomainPageSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TableSkeleton rows={1} cols={2} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
</div>
|
||||
<TableSkeleton rows={5} cols={5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainOverviewPage() {
|
||||
const { domainId } = Route.useParams()
|
||||
const { tab } = Route.useSearch()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
const {
|
||||
domain,
|
||||
bindings,
|
||||
services,
|
||||
serviceGroupById,
|
||||
subdomainRows,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
syncMutation,
|
||||
createSubdomainMutation,
|
||||
bindings,
|
||||
updateSubdomainMutation,
|
||||
deleteSubdomainMutation,
|
||||
linkServiceMutation,
|
||||
} = useDomainPage(id)
|
||||
|
||||
const runCheckMutation = useMutation({
|
||||
mutationFn: runHealthCheck,
|
||||
onSuccess: async (data) => {
|
||||
toast.success(`Проверено IP: ${data.checked}`)
|
||||
await queryClient.invalidateQueries({ queryKey: healthStatusKeys.all })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось запустить проверку',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
function openCreateSheet() {
|
||||
setSheetMode('create')
|
||||
setEditTarget(null)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openEditSheet(row: SubdomainTableRow) {
|
||||
setSheetMode('edit')
|
||||
setEditTarget(row)
|
||||
setSheetOpen(true)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const filterFields = useSubdomainFilterFields()
|
||||
@@ -146,13 +100,7 @@ function DomainOverviewPage() {
|
||||
return String(row.serviceLinks[0].serviceId)
|
||||
}
|
||||
|
||||
async function handleSheetSubmit(values: SubdomainEditValues) {
|
||||
if (sheetMode === 'create') {
|
||||
await createSubdomainMutation.mutateAsync(values.name)
|
||||
setSheetOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
async function handleEditSubmit(values: SubdomainEditValues) {
|
||||
if (!editTarget) return
|
||||
|
||||
const nameChanged = values.name !== editTarget.subdomain.name
|
||||
@@ -185,207 +133,69 @@ function DomainOverviewPage() {
|
||||
})
|
||||
}
|
||||
|
||||
setSheetOpen(false)
|
||||
setEditOpen(false)
|
||||
}
|
||||
|
||||
const isSheetSaving =
|
||||
createSubdomainMutation.isPending ||
|
||||
updateSubdomainMutation.isPending ||
|
||||
linkServiceMutation.isPending
|
||||
updateSubdomainMutation.isPending || linkServiceMutation.isPending
|
||||
|
||||
const metricCards = useMemo(() => {
|
||||
if (!domain) return []
|
||||
return [
|
||||
{
|
||||
id: 'status',
|
||||
icon: <GlobeIcon aria-hidden="true" />,
|
||||
label: 'Статус зоны',
|
||||
description: domain.status,
|
||||
footer: <StatusBadge status={domain.status} />,
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
icon: <FolderTreeIcon aria-hidden="true" />,
|
||||
label: 'Группа',
|
||||
description: domain.group_id ? 'Домен в группе' : 'Без группы',
|
||||
footer: domain.group_id ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть группу
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
icon: <Link2Icon aria-hidden="true" />,
|
||||
label: 'Привязки',
|
||||
description: 'Сервисы в зоне',
|
||||
footer: (
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
{bindings.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
icon: <ServerIcon aria-hidden="true" />,
|
||||
label: 'Синхронизация',
|
||||
description: 'Последняя синхронизация с Cloudflare',
|
||||
footer: (
|
||||
<span className="text-foreground text-sm font-medium tabular-nums">
|
||||
{formatDate(domain.last_synced_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [bindings.length, domain])
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать
|
||||
</LoadingButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => runCheckMutation.mutate()}
|
||||
isLoading={runCheckMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
>
|
||||
Проверить сейчас
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
const activeTab: DomainSectionTab = tab ?? 'overview'
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<DomainPageSkeleton />}
|
||||
>
|
||||
{domain && (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={domain.zone_name}
|
||||
description="Карточка домена"
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'availability', label: 'Доступность' },
|
||||
{
|
||||
id: 'subdomains',
|
||||
label: 'Поддомены',
|
||||
count: subdomainRows.length,
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
label: 'Привязки',
|
||||
count: bindings.length,
|
||||
},
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<DetailPanel.Metrics cards={metricCards} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||
<DomainAvailabilityPanel domainId={id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subdomains" className="flex flex-col gap-4">
|
||||
<ResourcePage
|
||||
title="Поддомены"
|
||||
description={`Записи в зоне ${domain.zone_name}`}
|
||||
hideHeader
|
||||
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={subdomainTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() =>
|
||||
setFilters(createDefaultSubdomainFilters())
|
||||
}
|
||||
getFilterFieldValue={subdomainFilterFieldValue}
|
||||
columns={columns}
|
||||
data={subdomainRows}
|
||||
getRowId={(row) => String(row.subdomain.id)}
|
||||
toolbarExtra={
|
||||
<Button type="button" onClick={openCreateSheet}>
|
||||
<DomainDetailChrome domainId={domainId} activeTab={activeTab}>
|
||||
{({ openCreateSubdomain }) => (
|
||||
<>
|
||||
{activeTab === 'availability' ? (
|
||||
<DomainAvailabilityPanel domainId={id} />
|
||||
) : null}
|
||||
{activeTab === 'subdomains' && domain ? (
|
||||
<ResourcePage
|
||||
title="Поддомены"
|
||||
description={`Записи в зоне ${domain.zone_name}`}
|
||||
hideHeader
|
||||
tabs={SUBDOMAIN_TABS.map((item) => ({ ...item }))}
|
||||
tabFilter={subdomainTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultSubdomainFilters())}
|
||||
getFilterFieldValue={subdomainFilterFieldValue}
|
||||
columns={columns}
|
||||
data={subdomainRows}
|
||||
getRowId={(row) => String(row.subdomain.id)}
|
||||
emptyState={{
|
||||
title: 'Поддомены не созданы',
|
||||
description: 'Добавьте поддомен для привязки сервисов',
|
||||
action: (
|
||||
<Button type="button" variant="outline" onClick={openCreateSubdomain}>
|
||||
Добавить поддомен
|
||||
</Button>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'Поддомены не созданы',
|
||||
description: 'Добавьте поддомен для привязки сервисов',
|
||||
action: (
|
||||
<Button type="button" onClick={openCreateSheet}>
|
||||
Добавить поддомен
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
||||
<DomainBindingsPanel bindings={bindings} />
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
|
||||
<SubdomainEditSheet
|
||||
mode={sheetMode}
|
||||
subdomain={editTarget?.subdomain ?? null}
|
||||
zoneName={domain.zone_name}
|
||||
services={services}
|
||||
serviceGroupById={serviceGroupById}
|
||||
currentServiceId={
|
||||
editTarget ? resolveServiceId(editTarget) : 'none'
|
||||
}
|
||||
serviceLinks={editTarget?.serviceLinks ?? []}
|
||||
open={sheetOpen}
|
||||
isSaving={isSheetSaving}
|
||||
onOpenChange={setSheetOpen}
|
||||
onSubmit={handleSheetSubmit}
|
||||
/>
|
||||
</DetailPanel>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'bindings' ? (
|
||||
<DomainBindingsPanel bindings={bindings} />
|
||||
) : null}
|
||||
{domain ? (
|
||||
<SubdomainEditSheet
|
||||
mode="edit"
|
||||
subdomain={editTarget?.subdomain ?? null}
|
||||
zoneName={domain.zone_name}
|
||||
services={services}
|
||||
serviceGroupById={serviceGroupById}
|
||||
currentServiceId={editTarget ? resolveServiceId(editTarget) : 'none'}
|
||||
serviceLinks={editTarget?.serviceLinks ?? []}
|
||||
open={editOpen}
|
||||
isSaving={isSheetSaving}
|
||||
onOpenChange={setEditOpen}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
</DomainDetailChrome>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user