Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b028d04b4 | ||
|
|
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 | ||
|
|
ba4e04a224 | ||
|
|
4c59780ff0 | ||
|
|
1457389ae7 | ||
|
|
153be28799 | ||
|
|
39fac7834f | ||
|
|
d267e40157 | ||
|
|
634a9dc362 | ||
|
|
1bf6cfa0d0 | ||
|
|
3da6de9311 | ||
|
|
7a8bacade9 | ||
|
|
5d84c7bf6c | ||
|
|
78811bc9b1 | ||
|
|
a458465153 | ||
|
|
69119a08a4 | ||
|
|
ba03d2be9d | ||
|
|
d8fc4ac949 |
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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { changeIpSchema } from "@cfdm/shared";
|
||||
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
|
||||
import * as bindingService from "../services/binding-service.js";
|
||||
import * as changeIp from "../services/change-ip-service.js";
|
||||
import { recordAudit } from "../lib/audit.js";
|
||||
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
||||
service_id: z.number().optional(),
|
||||
hostname: z.string().optional(),
|
||||
target_ip: z.string().optional(),
|
||||
cert_monitoring: certMonitoringSchema.optional(),
|
||||
});
|
||||
|
||||
app.get("/service-bindings", async (request) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
|
||||
import * as serviceConfig from "../services/service-config-service.js";
|
||||
import * as nodeService from "../services/node-service.js";
|
||||
import * as changeDomain from "../services/change-domain-service.js";
|
||||
import * as certificateService from "../services/certificate-service.js";
|
||||
import { recordAudit } from "../lib/audit.js";
|
||||
|
||||
export async function serviceRoutes(app: FastifyInstance) {
|
||||
@@ -69,10 +70,43 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
const { id } = request.params as { id: string };
|
||||
repos.getService(request.server.db, Number(id));
|
||||
return {
|
||||
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
|
||||
items: repos.listHealthProbeLogForService(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
200,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
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(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/services/:id/certificates/check", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const checked = await certificateService.runServiceChecks(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
);
|
||||
return { checked };
|
||||
});
|
||||
|
||||
app.get("/services/:id/overview", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return nodeService.getOverview(request.server.db, Number(id));
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
|
||||
service_id?: number;
|
||||
hostname?: string;
|
||||
target_ip?: string;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname?: string): string {
|
||||
@@ -92,6 +93,20 @@ export async function update(
|
||||
req: UpdateBindingRequest,
|
||||
): Promise<ServiceBindingView> {
|
||||
const existing = repos.getBinding(db, id);
|
||||
if (req.cert_monitoring !== undefined) {
|
||||
repos.updateBindingLbConfig(db, id, {
|
||||
cert_monitoring: req.cert_monitoring,
|
||||
});
|
||||
}
|
||||
|
||||
const hasIdentityPatch =
|
||||
req.service_id !== undefined ||
|
||||
req.hostname !== undefined ||
|
||||
req.target_ip !== undefined;
|
||||
if (!hasIdentityPatch) {
|
||||
return repos.getBindingView(db, id);
|
||||
}
|
||||
|
||||
const serviceId = req.service_id ?? existing.service_id;
|
||||
if (req.service_id) repos.getService(db, req.service_id);
|
||||
const hostname = req.hostname
|
||||
|
||||
@@ -2,7 +2,7 @@ import { connect } from "node:net";
|
||||
import { connect as tlsConnect } from "node:tls";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { Certificate, Domain, Subdomain } from "@cfdm/shared";
|
||||
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
||||
import {
|
||||
CERT_ERROR,
|
||||
CERT_MONITOR_AUTO,
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
CERT_UNKNOWN,
|
||||
certStatusFromExpiry,
|
||||
fqdnToDisplay,
|
||||
parseFqdn,
|
||||
shouldMonitorService,
|
||||
} from "@cfdm/shared";
|
||||
|
||||
export interface CertificateTarget {
|
||||
domainId: number;
|
||||
subdomainId: number | null;
|
||||
serviceId: number;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
|
||||
return repos.getCertificate(db, id);
|
||||
}
|
||||
|
||||
export function listServiceCertificates(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
): ServiceCertificateRow[] {
|
||||
repos.getService(db, serviceId);
|
||||
const certsByHost = new Map(
|
||||
repos.listCertificates(db).map((cert) => [cert.hostname, cert]),
|
||||
);
|
||||
return repos.listBindingsByService(db, serviceId).map((binding) => {
|
||||
const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);
|
||||
const cert = certsByHost.get(hostname);
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
domain_id: binding.domain_id,
|
||||
service_id: binding.service_id,
|
||||
hostname,
|
||||
cert_monitoring:
|
||||
(binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ??
|
||||
"auto",
|
||||
id: cert?.id ?? null,
|
||||
status: cert?.status ?? "unknown",
|
||||
expires_at: cert?.expires_at ?? null,
|
||||
last_checked_at: cert?.last_checked_at ?? null,
|
||||
last_error: cert?.last_error ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkHostname(
|
||||
hostname: string,
|
||||
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
||||
@@ -78,6 +106,7 @@ export async function checkAndStore(
|
||||
domainId: number,
|
||||
subdomainId: number | null,
|
||||
hostname: string,
|
||||
serviceId: number | null = null,
|
||||
): Promise<Certificate> {
|
||||
const { expiresAt, error } = await checkHostname(hostname);
|
||||
|
||||
@@ -90,6 +119,7 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_ERROR,
|
||||
error,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +135,7 @@ export async function checkAndStore(
|
||||
expiresAt.toISOString(),
|
||||
certStatusFromExpiry(days),
|
||||
null,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,23 +147,10 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_UNKNOWN,
|
||||
"unknown expiry",
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMonitoringMode(
|
||||
domain: Domain,
|
||||
subdomain: Subdomain | null,
|
||||
fqdn: string,
|
||||
): string {
|
||||
if (subdomain) {
|
||||
return subdomain.cert_monitoring;
|
||||
}
|
||||
if (fqdn === domain.zone_name) {
|
||||
return domain.cert_monitoring;
|
||||
}
|
||||
return CERT_MONITOR_AUTO;
|
||||
}
|
||||
|
||||
function bindingSubdomain(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
@@ -165,10 +183,9 @@ function hasSslHealthGate(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildServiceCertificateFqdns(
|
||||
db: Db,
|
||||
): Map<string, CertificateTarget> {
|
||||
const result = new Map<string, CertificateTarget>();
|
||||
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||
const targets: CertificateTarget[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const binding of repos.listAllBindings(db)) {
|
||||
const service = repos.getService(db, binding.service_id);
|
||||
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
|
||||
? repos.getServiceGroup(db, service.service_group_id)
|
||||
: null;
|
||||
if (!shouldMonitorService(service, group)) continue;
|
||||
if (
|
||||
!hasSslHealthGate(
|
||||
{
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
},
|
||||
group,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||
if (subdomain && !subdomain.enabled) continue;
|
||||
|
||||
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
|
||||
if (mode === CERT_MONITOR_SKIPPED) continue;
|
||||
if (mode === CERT_MONITOR_AUTO) {
|
||||
if (
|
||||
!hasSslHealthGate(
|
||||
{
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
},
|
||||
group,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} else if (mode !== CERT_MONITOR_REQUIRED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
|
||||
result.set(fqdn, {
|
||||
if (seen.has(fqdn)) continue;
|
||||
seen.add(fqdn);
|
||||
targets.push({
|
||||
domainId: binding.domain_id,
|
||||
subdomainId: subdomain?.id ?? null,
|
||||
serviceId: binding.service_id,
|
||||
hostname: fqdn,
|
||||
});
|
||||
}
|
||||
|
||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
||||
for (const group of repos.listServiceGroups(db)) {
|
||||
if (!group.enabled || !group.domain?.trim()) continue;
|
||||
if (!group.health_check_enabled || !group.health_check_verify_tls) continue;
|
||||
|
||||
const parsed = parseFqdn(group.domain, knownZones);
|
||||
if (!parsed) continue;
|
||||
|
||||
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
|
||||
if (!domain) continue;
|
||||
|
||||
const subdomain =
|
||||
parsed.hostname === "@"
|
||||
? null
|
||||
: bindingSubdomain(db, domain.id, parsed.hostname);
|
||||
if (subdomain && !subdomain.enabled) continue;
|
||||
|
||||
result.set(parsed.fqdn, {
|
||||
domainId: domain.id,
|
||||
subdomainId: subdomain?.id ?? null,
|
||||
hostname: parsed.fqdn,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||
const serviceFqdns = buildServiceCertificateFqdns(db);
|
||||
const targets = new Map<string, CertificateTarget>();
|
||||
|
||||
for (const domain of repos.listAllDomains(db)) {
|
||||
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
||||
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
||||
targets.set(domain.zone_name, {
|
||||
domainId: domain.id,
|
||||
subdomainId: null,
|
||||
hostname: domain.zone_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const sub of repos.listAllSubdomains(db)) {
|
||||
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
||||
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
||||
targets.set(sub.fqdn, {
|
||||
domainId: sub.domain_id,
|
||||
subdomainId: sub.id,
|
||||
hostname: sub.fqdn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fqdn, meta] of serviceFqdns) {
|
||||
const domain = repos.getDomain(db, meta.domainId);
|
||||
const subdomain = meta.subdomainId
|
||||
? repos.getSubdomain(db, meta.subdomainId)
|
||||
: null;
|
||||
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
|
||||
if (monitoring === CERT_MONITOR_SKIPPED) continue;
|
||||
if (
|
||||
monitoring === CERT_MONITOR_AUTO ||
|
||||
monitoring === CERT_MONITOR_REQUIRED
|
||||
) {
|
||||
targets.set(fqdn, meta);
|
||||
}
|
||||
}
|
||||
|
||||
return [...targets.values()];
|
||||
return targets;
|
||||
}
|
||||
|
||||
export async function runAllChecks(db: Db): Promise<number> {
|
||||
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
);
|
||||
}
|
||||
repos.deleteCertificatesNotIn(
|
||||
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
export async function runServiceChecks(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
): Promise<number> {
|
||||
repos.getService(db, serviceId);
|
||||
const targets = resolveCertificateTargets(db).filter(
|
||||
(target) => target.serviceId === serviceId,
|
||||
);
|
||||
for (const target of targets) {
|
||||
await checkAndStore(
|
||||
db,
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
);
|
||||
}
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
export function statusSummary(db: Db): Array<[string, number]> {
|
||||
pruneStaleCertificates(db);
|
||||
return repos.countCertificatesByStatus(db);
|
||||
|
||||
@@ -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]!];
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
normalizeDnsRecordName,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
@@ -28,14 +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 {
|
||||
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;
|
||||
@@ -209,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;
|
||||
}
|
||||
}
|
||||
@@ -225,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(
|
||||
@@ -252,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);
|
||||
@@ -283,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,
|
||||
@@ -308,14 +437,15 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
binding.health_check_provider ?? "local",
|
||||
],
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -343,6 +473,107 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTH_RANK: Record<string, number> = {
|
||||
down: 3,
|
||||
degraded: 2,
|
||||
unknown: 1,
|
||||
up: 0,
|
||||
};
|
||||
|
||||
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
const noDot = trimmed.replace(/\.+$/, "");
|
||||
const lower = noDot.toLowerCase();
|
||||
const keys = new Set([trimmed, noDot, lower]);
|
||||
if (zoneName && !lower.includes(".")) {
|
||||
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
type ServiceHealthRow = {
|
||||
ip: string;
|
||||
status: IpHealthState;
|
||||
latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
provider: ServiceView["ip_health"][number]["provider"];
|
||||
colo: string | null;
|
||||
};
|
||||
|
||||
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
|
||||
function fallbackCnameHealth(
|
||||
rows: ServiceHealthRow[],
|
||||
view: ServiceView,
|
||||
): ServiceHealthRow | undefined {
|
||||
const cnameKeys = new Set<string>();
|
||||
for (const domain of view.domains ?? []) {
|
||||
const cname = domain.target_cname?.trim();
|
||||
if (!cname) continue;
|
||||
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
|
||||
cnameKeys.add(key);
|
||||
}
|
||||
}
|
||||
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
|
||||
if (hostnameRows.length === 0) return undefined;
|
||||
const matched =
|
||||
cnameKeys.size === 0
|
||||
? hostnameRows
|
||||
: hostnameRows.filter((row) =>
|
||||
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
|
||||
);
|
||||
const candidates = matched.length > 0 ? matched : hostnameRows;
|
||||
return candidates.reduce((worst, row) =>
|
||||
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
|
||||
? row
|
||||
: worst,
|
||||
);
|
||||
}
|
||||
|
||||
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[],
|
||||
@@ -350,27 +581,60 @@ 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 byIp = new Map(
|
||||
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
|
||||
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) =>
|
||||
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
|
||||
),
|
||||
);
|
||||
const ip_health = (view.ips ?? []).map((ip) => {
|
||||
const row = byIp.get(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,
|
||||
};
|
||||
});
|
||||
@@ -473,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,
|
||||
@@ -505,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(
|
||||
@@ -519,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) {
|
||||
@@ -592,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) {
|
||||
@@ -611,6 +956,14 @@ async function syncBindingADns(
|
||||
|
||||
if (desiredIps.length === 0) {
|
||||
repos.setBindingDnsRecordId(db, bindingId, null);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -619,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,
|
||||
});
|
||||
}
|
||||
@@ -643,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);
|
||||
@@ -659,6 +1026,14 @@ async function syncBindingADns(
|
||||
}
|
||||
|
||||
repos.setBindingDnsRecordId(db, bindingId, primaryId);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupBindingDns(
|
||||
@@ -877,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,
|
||||
@@ -890,7 +1260,7 @@ async function syncServiceBindingsToDns(
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
targetIps,
|
||||
desiredIps,
|
||||
null,
|
||||
);
|
||||
}
|
||||
@@ -904,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -922,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;
|
||||
@@ -937,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,
|
||||
});
|
||||
}
|
||||
@@ -960,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);
|
||||
@@ -1017,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,
|
||||
@@ -1027,6 +1413,7 @@ async function syncGroupDomainDns(
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(group.lb_mode, fallbackIps),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1207,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,
|
||||
@@ -1420,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)) {
|
||||
@@ -1520,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,
|
||||
@@ -1544,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 },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -208,7 +208,7 @@ describe("certificates", () => {
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("required apex is monitored without bindings", async () => {
|
||||
it("required binding is monitored without TLS health gate", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -221,10 +221,18 @@ describe("certificates", () => {
|
||||
"required.example.com",
|
||||
"cf-zone-req",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
const service = repos.createService(testApp.db, "Req", "req");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
health_check_enabled: false,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
@@ -247,7 +255,7 @@ describe("certificates", () => {
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("skipped apex removes stale certificate on check", async () => {
|
||||
it("skipped binding removes stale certificate on check", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -260,6 +268,20 @@ describe("certificates", () => {
|
||||
"skipped.example.com",
|
||||
"cf-zone-skip",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Skip", "skip");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_SKIPPED,
|
||||
health_check_enabled: true,
|
||||
health_check_verify_tls: true,
|
||||
});
|
||||
repos.upsertCertificateCheck(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
@@ -268,12 +290,8 @@ describe("certificates", () => {
|
||||
null,
|
||||
CERT_ERROR,
|
||||
"stale",
|
||||
service.id,
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
cert_monitoring: CERT_MONITOR_SKIPPED,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: null,
|
||||
@@ -305,9 +323,16 @@ describe("certificates", () => {
|
||||
"broken.example.com",
|
||||
"cf-zone-broken",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
const service = repos.createService(testApp.db, "Broken", "broken");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
|
||||
@@ -424,7 +449,7 @@ describe("certificates", () => {
|
||||
});
|
||||
|
||||
const certs = repos.listCertificates(testApp.db);
|
||||
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true);
|
||||
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false);
|
||||
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
|
||||
|
||||
await testApp.close();
|
||||
@@ -443,12 +468,7 @@ describe("certificates", () => {
|
||||
"force.example.com",
|
||||
"cf-zone-force",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
repos.createServiceGroup(
|
||||
const group = repos.createServiceGroup(
|
||||
testApp.db,
|
||||
"Proxy",
|
||||
"vpn",
|
||||
@@ -459,6 +479,19 @@ describe("certificates", () => {
|
||||
health_check_verify_tls: false,
|
||||
},
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Force", "force");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
repos.setServiceGroup(testApp.db, service.id, group.id);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
@@ -540,4 +573,137 @@ describe("certificates", () => {
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("GET /services/:id/certificates lists binding FQDNs", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"svc.example.com",
|
||||
"cf-zone-svc",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Api", "api");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
repos.insertBinding(testApp.db, domain.id, service.id, "www", null);
|
||||
|
||||
const res = await testApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${service.id}/certificates`,
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const rows = res.json() as Array<{
|
||||
hostname: string;
|
||||
cert_monitoring: string;
|
||||
status: string;
|
||||
}>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.hostname).toBe("www.svc.example.com");
|
||||
expect(rows[0]?.cert_monitoring).toBe("auto");
|
||||
expect(rows[0]?.status).toBe("unknown");
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("PATCH /service-bindings/:id updates cert_monitoring", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"patch.example.com",
|
||||
"cf-zone-patch",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Patch", "patch");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"api",
|
||||
null,
|
||||
);
|
||||
|
||||
const res = await testApp.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/service-bindings/${binding.id}`,
|
||||
headers,
|
||||
payload: { cert_monitoring: CERT_MONITOR_REQUIRED },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe(
|
||||
CERT_MONITOR_REQUIRED,
|
||||
);
|
||||
expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe(
|
||||
CERT_MONITOR_REQUIRED,
|
||||
);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("POST /services/:id/certificates/check only checks that service", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"check.example.com",
|
||||
"cf-zone-check",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "One", "one");
|
||||
const other = repos.createService(testApp.db, "Two", "two");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
repos.setServiceEnabled(testApp.db, other.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"one",
|
||||
null,
|
||||
);
|
||||
const otherBinding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
other.id,
|
||||
"two",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
repos.updateBindingLbConfig(testApp.db, otherBinding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
error: null,
|
||||
});
|
||||
|
||||
const res = await testApp.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/services/${service.id}/certificates/check`,
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.json() as { checked: number }).checked).toBe(1);
|
||||
const certs = repos.listCertificates(testApp.db);
|
||||
expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true);
|
||||
expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -235,4 +235,320 @@ describe("health-check state derivation via runAllChecks", () => {
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.verify_tls).toBe(true);
|
||||
});
|
||||
|
||||
it("unwraps CNAME target to origin A record IPs", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"ihome",
|
||||
"2.59.161.102",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
"synced",
|
||||
"cf",
|
||||
null,
|
||||
);
|
||||
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||
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,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
});
|
||||
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.ip).toBe("2.59.161.102");
|
||||
expect(targets[0]?.hostname).toBe("s.rkns.top");
|
||||
});
|
||||
|
||||
it("unwraps CNAME target to service IP pool when origin DNS is empty", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||
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,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
});
|
||||
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
expect(targets).toHaveLength(1);
|
||||
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", () => {
|
||||
it("getView copies CNAME-keyed health onto the service IP row", 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 Sub", "rw-sub");
|
||||
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",
|
||||
binding.id,
|
||||
"ihome.rkns.top",
|
||||
"up",
|
||||
12,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
expect(view.ip_health).toEqual([
|
||||
expect.objectContaining({
|
||||
ip: "2.59.161.102",
|
||||
status: "up",
|
||||
latency_ms: 12,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
|
||||
expect(bindings).toHaveLength(2);
|
||||
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
||||
});
|
||||
|
||||
it("updateConfig with extra FQDN per IP does not throw when group health-check is on", async () => {
|
||||
const db = setupDb();
|
||||
const cf = mockCf();
|
||||
const health = {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp" as const,
|
||||
health_check_port: 443,
|
||||
health_check_providers: ["local", "cloudflare", "globalping"] as const,
|
||||
health_check_aggregate: "majority" as const,
|
||||
};
|
||||
|
||||
repos.createDomain(db, null, "example.com", "cf-zone-example");
|
||||
const group = repos.createServiceGroup(
|
||||
db,
|
||||
"VPN",
|
||||
"vpn",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
{ ...health },
|
||||
);
|
||||
const service = repos.createService(db, "GT", "gt");
|
||||
repos.setServiceGroup(db, service.id, group.id);
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["93.115.203.183", "130.49.213.153"],
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gt.example.com",
|
||||
target_ips: ["93.115.203.183", "130.49.213.153"],
|
||||
...health,
|
||||
},
|
||||
{
|
||||
fqdn: "rutg.example.com",
|
||||
target_ips: ["93.115.203.183"],
|
||||
...health,
|
||||
},
|
||||
{
|
||||
fqdn: "nsgt.example.com",
|
||||
target_ips: ["130.49.213.153"],
|
||||
...health,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(view.domains).toHaveLength(3);
|
||||
expect(view.ips.sort()).toEqual(["130.49.213.153", "93.115.203.183"]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serviceGroupsResponseSchema } from "@cfdm/shared";
|
||||
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import {
|
||||
listGroupViews,
|
||||
toggleServiceIp,
|
||||
updateConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
|
||||
@@ -51,6 +52,26 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
}
|
||||
|
||||
describe("create service then list groups", () => {
|
||||
it("accepts sqlite-shaped health fields on service config PATCH", () => {
|
||||
const parsed = updateServiceConfigSchema.parse({
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
health_check_enabled: 1,
|
||||
health_check_verify_tls: 0,
|
||||
health_check_providers: '["local","cloudflare"]',
|
||||
health_check_aggregate: "majority",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.domains?.[0]?.health_check_enabled).toBe(true);
|
||||
expect(parsed.domains?.[0]?.health_check_verify_tls).toBe(false);
|
||||
expect(parsed.domains?.[0]?.health_check_providers).toEqual([
|
||||
"local",
|
||||
"cloudflare",
|
||||
]);
|
||||
});
|
||||
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
@@ -133,6 +154,69 @@ describe("create service then list groups", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("PATCH /services/:id persists health providers and aggregate", 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 createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/services",
|
||||
headers,
|
||||
payload: { name: "GW", slug: "gw" },
|
||||
});
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4"],
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_providers: ["local", "cloudflare"],
|
||||
health_check_aggregate: "majority",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const stored = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||
expect(stored.health_check_enabled).toBe(true);
|
||||
expect(Array.isArray(stored.health_check_providers)).toBe(true);
|
||||
expect(stored.health_check_providers).toEqual(["local", "cloudflare"]);
|
||||
expect(stored.health_check_aggregate).toBe("majority");
|
||||
|
||||
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<{
|
||||
health_check_enabled: boolean;
|
||||
health_check_providers: string[];
|
||||
health_check_aggregate: string;
|
||||
}>;
|
||||
};
|
||||
expect(view.domains[0]?.health_check_enabled).toBe(true);
|
||||
expect(view.domains[0]?.health_check_providers).toEqual([
|
||||
"local",
|
||||
"cloudflare",
|
||||
]);
|
||||
expect(view.domains[0]?.health_check_aggregate).toBe("majority");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("POST /services returns resolved ServiceView with numeric id", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
@@ -167,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,
|
||||
@@ -197,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,
|
||||
@@ -206,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`,
|
||||
@@ -236,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>
|
||||
)
|
||||
}
|
||||
@@ -93,15 +93,11 @@ export function CertKpiStats({
|
||||
]
|
||||
}, [summary, total, activeTab, onSelectTab])
|
||||
|
||||
const hasData = (total ?? totalFromSummary(summary)) > 0
|
||||
|
||||
return (
|
||||
<KpiStatGrid
|
||||
cards={hasData ? cards : []}
|
||||
cards={cards}
|
||||
isLoading={isLoading}
|
||||
skeletonCount={4}
|
||||
emptyIcon={<ShieldCheckIcon className="text-muted-foreground size-5" aria-hidden />}
|
||||
emptyMessage="Нет данных — запустите проверку сертификатов"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GlobeIcon, SearchIcon, ServerIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
@@ -9,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||
import type { Certificate } from '@/lib/schemas'
|
||||
import { formatDate, formatRelative } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export const CERT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
|
||||
export function createDefaultCertFilters() {
|
||||
return [
|
||||
createFilter('hostname', 'contains', ['']),
|
||||
createFilter('service', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
]
|
||||
}
|
||||
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по хосту…',
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
label: 'Сервис',
|
||||
icon: <ServerIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по сервису…',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
||||
switch (field) {
|
||||
case 'hostname':
|
||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
||||
case 'service':
|
||||
return (item.service_name ?? '').toLowerCase()
|
||||
case 'status':
|
||||
return item.status
|
||||
default:
|
||||
@@ -82,7 +95,7 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function certRelativeBadge(status: string, expiresAt: string | null) {
|
||||
export function certRelativeBadge(status: string, expiresAt: string | null) {
|
||||
const relative = formatRelative(expiresAt)
|
||||
if (!expiresAt) {
|
||||
return <span className="text-muted-foreground tabular-nums">—</span>
|
||||
@@ -112,6 +125,39 @@ export function useCertificateColumns() {
|
||||
<span className="truncate font-medium">{row.original.hostname}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
accessorFn: (row) => row.service_name ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
column={column}
|
||||
title="Сервис"
|
||||
icon={<ServerIcon className="size-3.5" />}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const serviceId = row.original.service_id
|
||||
const name = row.original.service_name
|
||||
if (serviceId == null || !name) {
|
||||
return <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(serviceId) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
|
||||
@@ -207,7 +207,7 @@ export function useDomainColumns({
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Зона CF',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -18,6 +19,8 @@ export interface GroupCatalogRow {
|
||||
name: string
|
||||
slug: string
|
||||
domainCount: number
|
||||
serviceCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const GROUP_TABS = [
|
||||
@@ -114,9 +117,32 @@ export function useGroupColumns({
|
||||
<span className="tabular-nums">{row.original.domainCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'serviceCount',
|
||||
accessorKey: 'serviceCount',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.serviceCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Создана" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatRelative(sqliteUtcToIso(row.original.createdAt) ?? row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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: 'dns',
|
||||
label: 'DNS',
|
||||
count: dnsQuery.data ? dnsQuery.data.length : undefined,
|
||||
},
|
||||
{ id: 'bindings', label: 'Привязки', count: bindings.length },
|
||||
]}
|
||||
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'
|
||||
@@ -28,7 +22,7 @@ import {
|
||||
type HealthAggregate,
|
||||
type HealthProvider,
|
||||
} from '@/components/reui-kit/health-source-tiles'
|
||||
import { uniqueHealthProviders } from '@cfdm/shared'
|
||||
import { parseHealthProviders } from '@cfdm/shared'
|
||||
|
||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||
export type HealthCheckType = 'tcp' | 'http'
|
||||
@@ -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,16 +196,20 @@ 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>) {
|
||||
onChange({ ...value, ...next })
|
||||
}
|
||||
|
||||
const providers =
|
||||
value.providers?.length > 0
|
||||
? uniqueHealthProviders(value.providers)
|
||||
: uniqueHealthProviders([value.provider ?? 'local'])
|
||||
const providers = parseHealthProviders(
|
||||
value.providers,
|
||||
value.provider ?? 'local',
|
||||
)
|
||||
const aggregate = value.aggregate ?? 'majority'
|
||||
const isHttp = value.type === 'http'
|
||||
const rowClass = 'gap-3 px-0 py-3'
|
||||
@@ -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="Кто пробирует цель. Можно выбрать несколько источников."
|
||||
|
||||
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
|
||||
|
||||
interface HealthTimelineProps {
|
||||
events: HealthTimelineEvent[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
export function HealthTimeline({
|
||||
events,
|
||||
emptyTitle = 'Нет событий',
|
||||
emptyDescription = 'Результаты проверок появятся после первого прогона',
|
||||
}: HealthTimelineProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет событий"
|
||||
description="Результаты проверок появятся после первого прогона"
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
centered={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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 ? 'Синхронизация включена' : 'Синхронизация выключена'}
|
||||
@@ -126,6 +140,20 @@ export function VpsTrackerIntegrationCard({
|
||||
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!hasUnsaved}
|
||||
onClick={() =>
|
||||
form.reset({
|
||||
vpsTrackerUrl: settings?.vpsTrackerUrl ?? '',
|
||||
vpsTrackerIntegrationToken: '',
|
||||
vpsTrackerSyncEnabled: settings?.vpsTrackerSyncEnabled ?? false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => void handleTest()}>
|
||||
Проверить связь
|
||||
</Button>
|
||||
|
||||
@@ -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 { useMemo } from 'react'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -9,78 +10,17 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { DataFreshness } from '@/components/layout/data-freshness'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
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 {
|
||||
breadcrumb?: string
|
||||
}
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/domains': 'Домены',
|
||||
'/groups': 'Группы доменов',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
'/settings/appearance': 'Внешний вид',
|
||||
'/settings/health': 'Health-check',
|
||||
'/settings/integrations': 'Интеграции',
|
||||
}
|
||||
|
||||
function getBreadcrumbs(
|
||||
pathname: string,
|
||||
dynamicLabels: Record<string, string>,
|
||||
) {
|
||||
if (pathname === '/') {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Группы доменов', href: '/groups' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
||||
const domainId = pathname.split('/')[2]
|
||||
const domainPath = `/domains/${domainId}`
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||
{ label: 'DNS', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/settings')) {
|
||||
return [
|
||||
{ label: 'Настройки', href: '/settings/appearance' },
|
||||
...(pathname === '/settings/integrations'
|
||||
? [{ label: 'Интеграции', href: pathname }]
|
||||
: pathname === '/settings/health'
|
||||
? [{ label: 'Health-check', href: pathname }]
|
||||
: pathname === '/settings/appearance'
|
||||
? [{ label: 'Внешний вид', href: pathname }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
const title = routeTitles[pathname]
|
||||
if (title) {
|
||||
return [{ label: title, href: pathname }]
|
||||
}
|
||||
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
function useDynamicBreadcrumbLabels() {
|
||||
const matches = useMatches()
|
||||
return useMemo(() => {
|
||||
@@ -95,11 +35,14 @@ function useDynamicBreadcrumbLabels() {
|
||||
}, [matches])
|
||||
}
|
||||
|
||||
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||
/** Шапка макета: триггер, крошки, свежесть и состояние. Приложения — в сайдбаре. */
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||
const crumbs = useMemo(
|
||||
() => getBreadcrumbs(pathname, dynamicLabels),
|
||||
[pathname, dynamicLabels],
|
||||
)
|
||||
|
||||
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">
|
||||
@@ -110,10 +53,10 @@ export function SiteHeader() {
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return (
|
||||
<span key={crumb.href} className="contents">
|
||||
{index > 0 && (
|
||||
<Fragment key={`${index}-${crumb.href}`}>
|
||||
{index > 0 ? (
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
)}
|
||||
) : null}
|
||||
<BreadcrumbItem
|
||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||
>
|
||||
@@ -125,13 +68,23 @@ export function SiteHeader() {
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<DataFreshness />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Командная палитра"
|
||||
className="md:hidden"
|
||||
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
||||
>
|
||||
<SearchIcon aria-hidden />
|
||||
</Button>
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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,19 +191,20 @@ 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!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор CFDM</span>
|
||||
<span className="text-foreground text-sm font-semibold">Состояние системы</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
@@ -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 />
|
||||
|
||||
@@ -11,12 +11,14 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,14 @@ import {
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@cfdm/ui/components/toggle-group'
|
||||
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
|
||||
import type { HealthLogStatus } from '@/lib/health-log'
|
||||
|
||||
export type HealthProvider = HealthCheckProvider
|
||||
export type HealthAggregate = HealthCheckAggregate
|
||||
@@ -48,7 +54,7 @@ function GlobalpingMark() {
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ITEMS: Array<{
|
||||
export const HEALTH_PROVIDER_ITEMS: Array<{
|
||||
id: HealthProvider
|
||||
title: string
|
||||
description: string
|
||||
@@ -118,6 +124,7 @@ function ChoicePanel({
|
||||
icon,
|
||||
iconClassName,
|
||||
role,
|
||||
trailing,
|
||||
onActivate,
|
||||
}: {
|
||||
selected: boolean
|
||||
@@ -126,6 +133,7 @@ function ChoicePanel({
|
||||
icon: ReactNode
|
||||
iconClassName?: string
|
||||
role: 'checkbox' | 'radio'
|
||||
trailing?: ReactNode
|
||||
onActivate: () => void
|
||||
}) {
|
||||
return (
|
||||
@@ -157,12 +165,8 @@ function ChoicePanel({
|
||||
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
||||
<ItemDescription>{description}</ItemDescription>
|
||||
</ItemContent>
|
||||
{selected ? (
|
||||
<ItemActions className="shrink-0">
|
||||
<Badge variant="outline" size="sm">
|
||||
Выбрано
|
||||
</Badge>
|
||||
</ItemActions>
|
||||
{trailing ? (
|
||||
<ItemActions className="shrink-0">{trailing}</ItemActions>
|
||||
) : null}
|
||||
</Item>
|
||||
</FramePanel>
|
||||
@@ -189,7 +193,7 @@ export function HealthSourceTiles({
|
||||
value: HealthProvider[]
|
||||
onChange: (next: HealthProvider[]) => void
|
||||
}) {
|
||||
const selected = value.length > 0 ? value : (['local'] as HealthProvider[])
|
||||
const selected = parseHealthProviders(value)
|
||||
|
||||
function toggle(id: HealthProvider) {
|
||||
if (selected.includes(id)) {
|
||||
@@ -202,7 +206,7 @@ export function HealthSourceTiles({
|
||||
|
||||
return (
|
||||
<ChoiceFrame>
|
||||
{PROVIDER_ITEMS.map((item) => (
|
||||
{HEALTH_PROVIDER_ITEMS.map((item) => (
|
||||
<ChoicePanel
|
||||
key={item.id}
|
||||
selected={selected.includes(item.id)}
|
||||
@@ -211,6 +215,13 @@ export function HealthSourceTiles({
|
||||
icon={item.icon}
|
||||
iconClassName={item.iconClassName}
|
||||
role="checkbox"
|
||||
trailing={
|
||||
selected.includes(item.id) ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
Выбрано
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
onActivate={() => toggle(item.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -240,9 +251,142 @@ export function HealthAggregateTiles({
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
role="radio"
|
||||
trailing={
|
||||
selected === item.id ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
Выбрано
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
onActivate={() => onChange(item.id)}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function toggleProviders(
|
||||
active: HealthProvider[],
|
||||
id: HealthProvider,
|
||||
): HealthProvider[] {
|
||||
if (active.includes(id)) {
|
||||
if (active.length === 1) return active
|
||||
return active.filter((item) => item !== id)
|
||||
}
|
||||
return [...active, id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Компактный мультивыбор типа пробы (toolbar в stacked Frame).
|
||||
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function HealthSourceFilterBar({
|
||||
enabled,
|
||||
selected,
|
||||
statuses,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: HealthProvider[]
|
||||
selected: HealthProvider[]
|
||||
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||
onChange: (next: HealthProvider[]) => void
|
||||
}) {
|
||||
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
|
||||
if (visible.length === 0) return null
|
||||
|
||||
const active = selected.length > 0 ? selected : enabled
|
||||
|
||||
return (
|
||||
<div className="@container min-w-0 w-full">
|
||||
<ToggleGroup
|
||||
multiple
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex w-full min-w-0 flex-wrap justify-start"
|
||||
value={active}
|
||||
aria-label="Тип пробы"
|
||||
onValueChange={(next) => {
|
||||
const values = next.filter((value): value is HealthProvider =>
|
||||
visible.some((item) => item.id === value),
|
||||
)
|
||||
if (values.length === 0) return
|
||||
onChange(values)
|
||||
}}
|
||||
>
|
||||
{visible.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
aria-label={item.title}
|
||||
title={item.title}
|
||||
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
|
||||
>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={item.iconClassName}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
<span className="hidden min-w-0 truncate @[16rem]:inline">
|
||||
{item.title}
|
||||
</span>
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only status tiles for enabled probe sources; click filters the monitor.
|
||||
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function HealthProviderStatusTiles({
|
||||
enabled,
|
||||
selected,
|
||||
statuses,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: HealthProvider[]
|
||||
selected: HealthProvider[]
|
||||
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||
onChange: (next: HealthProvider[]) => void
|
||||
}) {
|
||||
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
|
||||
if (visible.length === 0) return null
|
||||
|
||||
const active = selected.length > 0 ? selected : enabled
|
||||
|
||||
return (
|
||||
<ChoiceFrame>
|
||||
{visible.map((item) => (
|
||||
<ChoicePanel
|
||||
key={item.id}
|
||||
selected={active.includes(item.id)}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
iconClassName={item.iconClassName}
|
||||
role="checkbox"
|
||||
trailing={
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
onActivate={() => onChange(toggleProviders(active, item.id))}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +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,
|
||||
@@ -14,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'
|
||||
@@ -21,6 +29,9 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
export {
|
||||
HealthSourceTiles,
|
||||
HealthAggregateTiles,
|
||||
HealthProviderStatusTiles,
|
||||
HealthSourceFilterBar,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from './health-source-tiles'
|
||||
export { ServiceAddressBlock } from './service-address-block'
|
||||
@@ -2,10 +2,9 @@ import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
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 { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
@@ -40,14 +39,70 @@ 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'
|
||||
type KpiTone = 'neutral' | 'ok' | 'in' | 'wn' | 'er' | 'primary'
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: 'text-foreground',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
const TILE: Record<KpiTone, string> = {
|
||||
neutral: 'text-muted-foreground',
|
||||
ok: 'text-success',
|
||||
in: 'text-info',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
primary: 'text-primary',
|
||||
}
|
||||
|
||||
const VALUE: Record<KpiTone, string> = {
|
||||
neutral: 'text-foreground',
|
||||
ok: 'text-foreground',
|
||||
in: 'text-foreground',
|
||||
primary: 'text-foreground',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
}
|
||||
|
||||
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'info-light' | 'warning-light' | 'destructive-light' | 'primary-light'> = {
|
||||
neutral: 'outline',
|
||||
ok: 'success-light',
|
||||
in: 'info-light',
|
||||
wn: 'warning-light',
|
||||
er: 'destructive-light',
|
||||
primary: 'primary-light',
|
||||
}
|
||||
|
||||
const GLYPH: Record<KpiTone, string> = {
|
||||
neutral: '',
|
||||
ok: '●',
|
||||
in: '',
|
||||
primary: '',
|
||||
wn: '▲',
|
||||
er: '✕',
|
||||
}
|
||||
|
||||
const COLS: Record<number, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2 max-[640px]:grid-cols-1',
|
||||
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
|
||||
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
|
||||
}
|
||||
|
||||
function iconTone(item: KpiStatItem): KpiTone {
|
||||
if (item.iconClassName?.includes('text-destructive')) return 'er'
|
||||
if (item.iconClassName?.includes('text-warning')) return 'wn'
|
||||
if (item.iconClassName?.includes('text-success')) return 'ok'
|
||||
if (item.iconClassName?.includes('text-info')) return 'in'
|
||||
if (item.iconClassName?.includes('text-primary')) return 'primary'
|
||||
if (item.variant === 'destructive') return 'er'
|
||||
if (item.variant === 'warning') return 'wn'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function statusTone(item: KpiStatItem): KpiTone {
|
||||
if (item.variant === 'destructive') return 'er'
|
||||
if (item.variant === 'warning') return 'wn'
|
||||
return iconTone(item)
|
||||
}
|
||||
|
||||
/** Полоса KPI: одна рамка, иконка слева, значение и чип. https://reui.io/preview/base/stats-12 */
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
@@ -63,11 +118,16 @@ function isSelected(item: KpiStatItem): boolean {
|
||||
return Boolean(item.selected ?? item.active)
|
||||
}
|
||||
|
||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
function resolveFooter(item: KpiStatItem, tone: KpiTone): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
<Badge variant={BADGE[tone]} size="sm" className="max-w-full whitespace-nowrap">
|
||||
{GLYPH[tone] ? (
|
||||
<span aria-hidden className="text-[0.55rem]">
|
||||
{GLYPH[tone]}
|
||||
</span>
|
||||
) : null}
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
@@ -77,49 +137,43 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
}
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
const tone = iconTone(item)
|
||||
const status = statusTone(item)
|
||||
const footer = resolveFooter(item, status)
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[tone])}>
|
||||
{item.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">
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[status])}>
|
||||
{item.value}
|
||||
</span>
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function panelClassName(item: KpiStatItem, className?: string) {
|
||||
function panelClassName(item: KpiStatItem, className?: string, flush = false) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
return cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
clickable &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
flush
|
||||
? 'bg-card flex h-full min-w-0 items-center'
|
||||
: 'relative isolate flex h-full min-w-0 flex-col',
|
||||
clickable && 'hover:bg-muted/40 cursor-pointer transition-colors',
|
||||
!flush && clickable && 'focus-within:ring-ring focus-within:ring-2',
|
||||
selected && (flush ? 'bg-accent/70' : 'ring-primary/30 bg-muted/30 ring-1'),
|
||||
className,
|
||||
)
|
||||
}
|
||||
@@ -128,28 +182,31 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
||||
export function KpiStatCardTile({
|
||||
item,
|
||||
embedded = false,
|
||||
flush = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
flush?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item, className)
|
||||
const panelClass = panelClassName(item, className, flush)
|
||||
const Shell = flush ? 'div' : FramePanel
|
||||
|
||||
let panel: ReactNode
|
||||
|
||||
if (item.to) {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<Shell className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none w-full">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
</Shell>
|
||||
)
|
||||
} else if (onActivate) {
|
||||
panel = (
|
||||
<FramePanel
|
||||
<Shell
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
@@ -157,16 +214,18 @@ export function KpiStatCardTile({
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
</Shell>
|
||||
)
|
||||
} else {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Shell className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
if (flush) return panel
|
||||
|
||||
if (embedded) {
|
||||
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||
}
|
||||
@@ -187,23 +246,26 @@ export function KpiStatCard({
|
||||
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||
}
|
||||
|
||||
function statGridClass(count: number) {
|
||||
return COLS[Math.min(Math.max(count, 1), 4)] ?? COLS[4]
|
||||
}
|
||||
|
||||
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" />
|
||||
<Frame spacing="sm" className="w-full min-w-0">
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', statGridClass(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className="bg-card flex items-center gap-3 px-[18px] py-3.5">
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="h-7 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -223,44 +285,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,
|
||||
@@ -270,7 +298,6 @@ export function KpiStatGrid({
|
||||
emptyIcon,
|
||||
className,
|
||||
skeletonCount = 4,
|
||||
embedded = false,
|
||||
'aria-label': ariaLabel,
|
||||
}: KpiStatGridProps) {
|
||||
if (isLoading) {
|
||||
@@ -292,25 +319,21 @@ 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))}>
|
||||
return (
|
||||
<Frame spacing="sm" aria-label={ariaLabel} className={cn('w-full min-w-0', className)}>
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', statGridClass(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
<div key={kpiStatItemKey(item, index)} className="bg-card min-w-0">
|
||||
<KpiStatCardTile
|
||||
item={item}
|
||||
flush
|
||||
className="bg-card px-[18px] py-3.5 shadow-none ring-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,12 +8,21 @@ import {
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
Columns3Icon,
|
||||
FilterIcon,
|
||||
FunnelXIcon,
|
||||
SearchIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
@@ -31,6 +40,7 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import {
|
||||
@@ -67,7 +77,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 +149,9 @@ export function ResourcePage<T extends object>({
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
filteredEmptyState,
|
||||
onFilteredEmptyReset,
|
||||
errorState,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
selectionToolbar,
|
||||
@@ -129,6 +162,8 @@ export function ResourcePage<T extends object>({
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [query, setQuery] = useState('')
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
@@ -143,11 +178,15 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
const needle = query.trim().toLowerCase()
|
||||
if (needle) {
|
||||
result = result.filter((item) => JSON.stringify(item).toLowerCase().includes(needle))
|
||||
}
|
||||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||
result = result.filter((item) => tabFilter(item, activeTab))
|
||||
}
|
||||
return result
|
||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
||||
}, [data, filters, getFilterFieldValue, query, tabs, tabFilter, activeTab])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
if (!tabs?.length || !tabFilter) return {}
|
||||
@@ -177,9 +216,10 @@ export function ResourcePage<T extends object>({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId,
|
||||
state: { sorting, rowSelection, pagination },
|
||||
state: { sorting, columnVisibility, rowSelection, pagination },
|
||||
enableRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -224,6 +264,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 +321,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 +331,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">
|
||||
@@ -338,19 +421,46 @@ export function ResourcePage<T extends object>({
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<div className="relative w-full max-w-xs">
|
||||
<SearchIcon
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value)
|
||||
resetPagination()
|
||||
}}
|
||||
aria-label="Поиск"
|
||||
placeholder="Поиск"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Колонки">
|
||||
<Columns3Icon className="size-4" aria-hidden />
|
||||
Колонки
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { ServerIcon, Trash2Icon } from 'lucide-react'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { isValidIpv4 } from '@/components/tagged-input'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
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'
|
||||
import { Field, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@cfdm/ui/components/input-group'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
function ZoneAddon({
|
||||
fqdn,
|
||||
zoneHints,
|
||||
trailing,
|
||||
}: {
|
||||
fqdn: string
|
||||
zoneHints: string[]
|
||||
trailing?: ReactNode
|
||||
}) {
|
||||
const parsed = parseFqdn(fqdn, zoneHints)
|
||||
if (!parsed && !fqdn.trim() && !trailing) return null
|
||||
return (
|
||||
<InputGroupAddon align="inline-end">
|
||||
{parsed ? (
|
||||
<Badge variant="outline" size="xs" className="font-mono">
|
||||
{parsed.zoneName}
|
||||
</Badge>
|
||||
) : fqdn.trim() ? (
|
||||
<Badge variant="warning-light" size="xs">
|
||||
зона не найдена
|
||||
</Badge>
|
||||
) : null}
|
||||
{trailing}
|
||||
</InputGroupAddon>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый блок адресов сервиса: список общих 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
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function ServiceAddressBlock({
|
||||
value,
|
||||
onChange,
|
||||
zoneHints,
|
||||
}: {
|
||||
value: AddressBlockState
|
||||
onChange: (next: AddressBlockState) => void
|
||||
zoneHints: string[]
|
||||
}) {
|
||||
const [pendingIp, setPendingIp] = useState('')
|
||||
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()
|
||||
const pendingFqdnTrimmed = pendingFqdn.trim()
|
||||
const pendingIpInvalid =
|
||||
ipInvalid && pendingIpTrimmed.length > 0 && !isValidIpv4(pendingIpTrimmed)
|
||||
const pendingFqdnInvalid =
|
||||
fqdnInvalid && pendingFqdnTrimmed.length > 0
|
||||
|
||||
function tryAddFqdn(raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
setFqdnInvalid(false)
|
||||
return
|
||||
}
|
||||
if (addressHasFqdn(value, trimmed)) {
|
||||
setFqdnInvalid(true)
|
||||
return
|
||||
}
|
||||
onChange(addCommonFqdn(value, trimmed))
|
||||
setPendingFqdn('')
|
||||
setFqdnInvalid(false)
|
||||
}
|
||||
|
||||
function tryAddIp(raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
setIpInvalid(false)
|
||||
return
|
||||
}
|
||||
if (!isValidIpv4(trimmed) || pool.includes(trimmed)) {
|
||||
setIpInvalid(true)
|
||||
return
|
||||
}
|
||||
onChange(addAddressNode(value, trimmed))
|
||||
setPendingIp('')
|
||||
setIpInvalid(false)
|
||||
}
|
||||
|
||||
function handleFqdnKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
tryAddFqdn(pendingFqdn)
|
||||
}
|
||||
}
|
||||
|
||||
function handleIpKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
tryAddIp(pendingIp)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<Frame stacked dense spacing="sm" className="w-full min-w-0">
|
||||
<FramePanel fit className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 pt-0">
|
||||
<FrameTitle>Адреса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Общие FQDN — на весь пул · у IP свои доп. домены
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="service-common-fqdn-add">Общие домены (FQDN)</FieldLabel>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{value.commonFqdns.map((fqdn, index) => (
|
||||
<InputGroup key={`common-fqdn-${index}`}>
|
||||
<InputGroupInput
|
||||
id={`service-common-fqdn-${index}`}
|
||||
className="font-mono"
|
||||
value={fqdn}
|
||||
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
|
||||
onChange={(event) =>
|
||||
onChange(updateCommonFqdn(value, index, event.target.value))
|
||||
}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={fqdn}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Удалить ${fqdn || 'FQDN'}`}
|
||||
onClick={() => onChange(removeCommonFqdn(value, index))}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
))}
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="service-common-fqdn-add"
|
||||
className="font-mono"
|
||||
value={pendingFqdn}
|
||||
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
|
||||
aria-invalid={pendingFqdnInvalid || undefined}
|
||||
onChange={(event) => {
|
||||
setPendingFqdn(event.target.value)
|
||||
setFqdnInvalid(false)
|
||||
}}
|
||||
onKeyDown={handleFqdnKeyDown}
|
||||
onBlur={() => tryAddFqdn(pendingFqdn)}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={pendingFqdn}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton size="sm" onClick={() => tryAddFqdn(pendingFqdn)}>
|
||||
Добавить
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</Field>
|
||||
</FramePanel>
|
||||
|
||||
<FramePanel fit className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 pt-0">
|
||||
<FrameTitle>IP-адреса</FrameTitle>
|
||||
</FrameHeader>
|
||||
{value.nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Добавьте IP пула"
|
||||
description="IPv4 сервиса. Для каждого адреса можно указать несколько доп. FQDN, в том числе wildcard."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{value.nodes.map((node) => {
|
||||
return (
|
||||
<Item
|
||||
key={node.ip}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="items-stretch"
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className="text-info"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ServerIcon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ItemTitle className="font-mono">{node.ip}</ItemTitle>
|
||||
<ItemActions className="ml-auto shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить ${node.ip}`}
|
||||
onClick={() => onChange(removeAddressNode(value, node.ip))}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</div>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel
|
||||
htmlFor={`service-ip-extra-add-${node.ip}`}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Доп. FQDN
|
||||
</FieldLabel>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
)}
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="service-pool-ip-add"
|
||||
className="font-mono"
|
||||
value={pendingIp}
|
||||
placeholder="192.168.1.1"
|
||||
aria-invalid={pendingIpInvalid || undefined}
|
||||
onChange={(event) => {
|
||||
setPendingIp(event.target.value)
|
||||
setIpInvalid(false)
|
||||
}}
|
||||
onKeyDown={handleIpKeyDown}
|
||||
onBlur={() => tryAddIp(pendingIp)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
|
||||
Добавить
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
||||
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
|
||||
import {
|
||||
collapseStatusChanges,
|
||||
filterByPeriod,
|
||||
filterByProviders,
|
||||
type HealthLogProbe,
|
||||
type HealthLogStatus,
|
||||
} from '@/lib/health-log'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
/**
|
||||
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
|
||||
* + график (chart-17) + таймлайн смен статуса.
|
||||
*
|
||||
* Preview: https://reui.io/preview/base/list-9
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Preview: https://reui.io/preview/base/solution-ai-ops-1
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function ServiceHealthMonitor({
|
||||
items,
|
||||
enabledProviders,
|
||||
statuses,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: HealthLogProbe[]
|
||||
enabledProviders: readonly HealthCheckProvider[]
|
||||
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
const enabled = useMemo(
|
||||
() => [...enabledProviders],
|
||||
[enabledProviders],
|
||||
)
|
||||
|
||||
const activeProviders = useMemo((): HealthCheckProvider[] => {
|
||||
const picked = (selected ?? enabled).filter((provider) =>
|
||||
enabled.includes(provider),
|
||||
)
|
||||
return picked.length > 0 ? picked : enabled
|
||||
}, [enabled, selected])
|
||||
|
||||
const periodItems = useMemo(
|
||||
() => filterByPeriod(items, days),
|
||||
[items, days],
|
||||
)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterByProviders(periodItems, activeProviders),
|
||||
[periodItems, activeProviders],
|
||||
)
|
||||
|
||||
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||
<FramePanel>
|
||||
<HealthSourceFilterBar
|
||||
enabled={enabled}
|
||||
selected={activeProviders}
|
||||
statuses={statuses}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
</FramePanel>
|
||||
|
||||
<UptimeChart
|
||||
items={filtered}
|
||||
isLoading={isLoading}
|
||||
period={period}
|
||||
onPeriodChange={setPeriod}
|
||||
skipPeriodFilter
|
||||
embedded
|
||||
hideHeader
|
||||
/>
|
||||
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 py-0">
|
||||
<FrameTitle>Смены статуса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Только переходы up / degraded / down · Cloudflare = Worker, не Health
|
||||
Checks API
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<HealthTimeline
|
||||
events={changes.map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.ip,
|
||||
type: row.provider,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
colo: row.colo,
|
||||
provider: row.provider,
|
||||
}))}
|
||||
emptyTitle="Нет смен статуса"
|
||||
emptyDescription="События появятся при переходе up / degraded / down"
|
||||
/>
|
||||
</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 }
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
|
||||
|
||||
function probe(
|
||||
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
|
||||
): UptimeProbe {
|
||||
return {
|
||||
status: 'up',
|
||||
ok: true,
|
||||
latency_ms: 10,
|
||||
checked_at: '2026-01-01T00:00:00.000Z',
|
||||
provider: 'local',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('toAlignedSeries', () => {
|
||||
it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => {
|
||||
const { points, keys } = toAlignedSeries([
|
||||
probe({
|
||||
id: 1,
|
||||
provider: 'local',
|
||||
latency_ms: 4,
|
||||
checked_at: '2026-01-01T00:00:10.000Z',
|
||||
}),
|
||||
probe({
|
||||
id: 2,
|
||||
provider: 'cloudflare',
|
||||
latency_ms: 284,
|
||||
checked_at: '2026-01-01T00:00:12.000Z',
|
||||
}),
|
||||
probe({
|
||||
id: 3,
|
||||
provider: 'globalping',
|
||||
latency_ms: 38,
|
||||
checked_at: '2026-01-01T00:00:40.000Z',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]?.local).toBe(4)
|
||||
expect(points[0]?.cloudflare).toBe(284)
|
||||
expect(points[0]?.globalping).toBe(38)
|
||||
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({
|
||||
id: 1,
|
||||
status: 'down',
|
||||
ok: false,
|
||||
latency_ms: 12,
|
||||
provider: 'local',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]?.local).toBeNull()
|
||||
expect(points[0]?.localOk).toBe(false)
|
||||
expect(points[0]?.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('splits probes that fall into adjacent minutes', () => {
|
||||
const { points } = toAlignedSeries([
|
||||
probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }),
|
||||
probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }),
|
||||
])
|
||||
|
||||
expect(points).toHaveLength(2)
|
||||
expect(points[0]?.local).toBe(10)
|
||||
expect(points[1]?.local).toBe(20)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { filterByPeriod, probeTime } from '@/lib/health-log'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
/**
|
||||
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Frame: https://reui.io/docs/components/base/frame
|
||||
* Chart: shadcn Chart + Recharts ComposedChart
|
||||
*/
|
||||
|
||||
export interface UptimeProbe {
|
||||
id: number
|
||||
status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||
ok: boolean
|
||||
latency_ms: number | null
|
||||
checked_at: string
|
||||
provider?: HealthCheckProvider
|
||||
}
|
||||
|
||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||
|
||||
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
|
||||
{ key: '5D', label: '5D', days: 5 },
|
||||
{ key: '2W', label: '2W', days: 14 },
|
||||
{ key: '1M', label: '1M', days: 30 },
|
||||
]
|
||||
|
||||
export const UPTIME_BUCKET_MS = 60_000
|
||||
|
||||
export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const
|
||||
|
||||
export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number]
|
||||
|
||||
const chartConfig = {
|
||||
local: {
|
||||
label: 'Local',
|
||||
color: 'var(--info)',
|
||||
},
|
||||
cloudflare: {
|
||||
label: 'Cloudflare',
|
||||
color: 'var(--warning)',
|
||||
},
|
||||
globalping: {
|
||||
label: 'Globalping',
|
||||
color: 'var(--success)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export interface AlignedChartPoint {
|
||||
period: string
|
||||
at: string
|
||||
ok: boolean
|
||||
local?: number | null
|
||||
cloudflare?: number | null
|
||||
globalping?: number | null
|
||||
localOk?: boolean
|
||||
cloudflareOk?: boolean
|
||||
globalpingOk?: boolean
|
||||
}
|
||||
|
||||
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
|
||||
return value === 'local' || value === 'cloudflare' || value === 'globalping'
|
||||
}
|
||||
|
||||
function bucketStart(time: number): number {
|
||||
return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS
|
||||
}
|
||||
|
||||
function providerOf(item: UptimeProbe): UptimeProviderKey {
|
||||
return isProviderKey(item.provider) ? item.provider : 'local'
|
||||
}
|
||||
|
||||
function probeOk(item: UptimeProbe): boolean {
|
||||
return item.ok && item.status !== 'down'
|
||||
}
|
||||
|
||||
/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */
|
||||
export function toAlignedSeries(items: UptimeProbe[]): {
|
||||
points: AlignedChartPoint[]
|
||||
keys: UptimeProviderKey[]
|
||||
} {
|
||||
const buckets = new Map<number, AlignedChartPoint>()
|
||||
const used = new Set<UptimeProviderKey>()
|
||||
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||
)
|
||||
|
||||
for (const item of sorted) {
|
||||
const key = providerOf(item)
|
||||
used.add(key)
|
||||
const start = bucketStart(probeTime(item.checked_at))
|
||||
let row = buckets.get(start)
|
||||
if (!row) {
|
||||
row = {
|
||||
period: formatDate(item.checked_at),
|
||||
at: item.checked_at,
|
||||
ok: true,
|
||||
}
|
||||
buckets.set(start, row)
|
||||
}
|
||||
|
||||
const ok = probeOk(item)
|
||||
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()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, row]) => {
|
||||
const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined)
|
||||
return {
|
||||
...row,
|
||||
ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false),
|
||||
}
|
||||
})
|
||||
|
||||
const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key))
|
||||
return { points, keys }
|
||||
}
|
||||
|
||||
function uptimePercent(points: AlignedChartPoint[]): number | null {
|
||||
if (points.length === 0) return null
|
||||
const okCount = points.filter((point) => point.ok).length
|
||||
return (okCount / points.length) * 100
|
||||
}
|
||||
|
||||
function deltaPercent(points: AlignedChartPoint[]): number | null {
|
||||
if (points.length < 4) return null
|
||||
const mid = Math.floor(points.length / 2)
|
||||
const prev = uptimePercent(points.slice(0, mid))
|
||||
const next = uptimePercent(points.slice(mid))
|
||||
if (prev == null || next == null) return null
|
||||
return next - prev
|
||||
}
|
||||
|
||||
function UptimeDelta({ delta }: { delta: number }) {
|
||||
if (Math.abs(delta) < 0.05) {
|
||||
return <span className="text-muted-foreground">без изменений за период</span>
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return (
|
||||
<>
|
||||
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||||
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
|
||||
<span className="text-muted-foreground">с начала периода</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||||
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
|
||||
<span className="text-muted-foreground">с начала периода</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||
return uptimePercent(toAlignedSeries(items).points)
|
||||
}
|
||||
|
||||
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||||
if (items.length === 0) return null
|
||||
const latest = [...items].sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
|
||||
)[0]
|
||||
return latest?.latency_ms ?? null
|
||||
}
|
||||
|
||||
function formatUptime(value: number | null): string {
|
||||
if (value == null) return '—'
|
||||
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
||||
}
|
||||
|
||||
function formatPing(value: unknown, ok: boolean | undefined): string {
|
||||
if (ok === false) return '—'
|
||||
const ping = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(ping) ? `${ping} мс` : '—'
|
||||
}
|
||||
|
||||
interface UptimeChartProps {
|
||||
items: UptimeProbe[]
|
||||
isLoading?: boolean
|
||||
period?: UptimePeriodKey
|
||||
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||
skipPeriodFilter?: boolean
|
||||
embedded?: boolean
|
||||
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
export function UptimeChart({
|
||||
items,
|
||||
isLoading = false,
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
skipPeriodFilter = false,
|
||||
embedded = false,
|
||||
hideHeader = false,
|
||||
}: UptimeChartProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
|
||||
const period = periodProp ?? internalPeriod
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
function handlePeriodChange(next: UptimePeriodKey) {
|
||||
onPeriodChange?.(next)
|
||||
if (periodProp == null) setInternalPeriod(next)
|
||||
setHovered(null)
|
||||
}
|
||||
|
||||
const { points, keys } = useMemo(
|
||||
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||
[items, days, skipPeriodFilter],
|
||||
)
|
||||
const uptime = uptimePercent(points)
|
||||
const delta = deltaPercent(points)
|
||||
const lastOk = points.at(-1)?.ok ?? true
|
||||
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||
const single = keys.length <= 1
|
||||
const areaKey = keys[0] ?? 'local'
|
||||
const hoverPings = hovered
|
||||
? keys.map((key) => {
|
||||
const ok = hovered[`${key}Ok`]
|
||||
const label = chartConfig[key].label
|
||||
return `${label} ${formatPing(hovered[key], ok)}`
|
||||
})
|
||||
: []
|
||||
|
||||
function syncHover(state: {
|
||||
activeTooltipIndex?: unknown
|
||||
activeIndex?: unknown
|
||||
}) {
|
||||
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
|
||||
if (!Number.isFinite(index)) return
|
||||
setHovered(points[index] ?? null)
|
||||
}
|
||||
|
||||
const panel = (
|
||||
<FramePanel className="flex flex-col gap-6 overflow-visible">
|
||||
{hideHeader ? null : (
|
||||
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={`size-10.5 ${tileClass}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ActivityIcon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h3 className="text-base font-semibold">Uptime</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пробы health-check за период
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delay={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="О графике uptime"
|
||||
className="text-muted-foreground/70 -mr-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||||
) : points.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет проб за период"
|
||||
description="Результаты появятся после health-check"
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||
{formatUptime(uptime)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{delta == null ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{points.length} проб
|
||||
</Badge>
|
||||
) : (
|
||||
<UptimeDelta delta={delta} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hoverPings.length > 0 ? (
|
||||
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
|
||||
<span className="text-foreground font-medium">Пинг</span>
|
||||
{' · '}
|
||||
{formatDate(hovered?.at)}
|
||||
{' · '}
|
||||
{hoverPings.join(' · ')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground min-h-4 text-xs">
|
||||
Наведите на точку графика
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="h-40 w-full overflow-visible">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
|
||||
initialDimension={{ width: 320, height: 160 }}
|
||||
>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
|
||||
accessibilityLayer
|
||||
onMouseMove={syncHover}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={syncHover}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={`var(--color-${areaKey})`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={`var(--color-${areaKey})`}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="at" hide />
|
||||
<YAxis hide domain={['auto', 'auto']} />
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||
filterNull={false}
|
||||
shared
|
||||
isAnimationActive={false}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_label, payload) => {
|
||||
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
|
||||
return at ? formatDate(at) : String(_label ?? '')
|
||||
}}
|
||||
formatter={(value, name, item) => {
|
||||
const key = String(name)
|
||||
const row = item.payload as AlignedChartPoint | undefined
|
||||
const ok =
|
||||
key === 'local' || key === 'cloudflare' || key === 'globalping'
|
||||
? row?.[`${key}Ok`]
|
||||
: row?.ok
|
||||
const label = chartConfig[key as UptimeProviderKey]?.label ?? key
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{formatPing(value, ok)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{single ? (
|
||||
<Area
|
||||
dataKey={areaKey}
|
||||
name={areaKey}
|
||||
type="monotone"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={`var(--color-${areaKey})`}
|
||||
strokeWidth={2}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={key}
|
||||
name={key}
|
||||
type="monotone"
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={period}
|
||||
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{UPTIME_PERIODS.map((entry) => (
|
||||
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
)
|
||||
|
||||
if (embedded) return panel
|
||||
|
||||
return (
|
||||
<Frame spacing="sm" className="min-w-0 w-full">
|
||||
{panel}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function DataGridColumnVisibility<TData>({
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
Колонки
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||
import { ServiceAddressBlock } from '@/components/reui-kit/service-address-block'
|
||||
import {
|
||||
HealthCheckConfigFields,
|
||||
type LbAndHealthConfig,
|
||||
type LbMode,
|
||||
type HealthCheckType,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
@@ -18,8 +13,17 @@ import type {
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
DEFAULT_BINDING_HEALTH,
|
||||
emptyAddressBlock,
|
||||
hydrateAddressBlock,
|
||||
patchAddressIpMeta,
|
||||
toBindingDrafts,
|
||||
toDomainsPayload,
|
||||
type AddressBlockState,
|
||||
type BindingHealthConfig,
|
||||
type ServiceBindingDraft,
|
||||
} from '@/lib/service-address'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Sheet,
|
||||
@@ -29,14 +33,8 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
Select,
|
||||
@@ -46,44 +44,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
|
||||
interface BindingHealthConfig {
|
||||
enabled: boolean
|
||||
type: HealthCheckType
|
||||
port: number | null
|
||||
path: string | null
|
||||
expected_status: number | null
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
}
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
fqdn: string
|
||||
record_type: 'A' | 'CNAME'
|
||||
target_ips: string[]
|
||||
target_cname: string
|
||||
lb_mode: LbMode
|
||||
health: BindingHealthConfig
|
||||
target_ip_weights: Record<string, number>
|
||||
target_ip_priorities: Record<string, number>
|
||||
}
|
||||
|
||||
const defaultHealth: BindingHealthConfig = {
|
||||
enabled: false,
|
||||
type: 'tcp',
|
||||
port: null,
|
||||
path: null,
|
||||
expected_status: null,
|
||||
interval_sec: 30,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
providers: ['local'],
|
||||
aggregate: 'majority',
|
||||
}
|
||||
export type { ServiceBindingDraft }
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
@@ -100,104 +61,19 @@ interface ServiceEditSheetProps {
|
||||
onDelete?: (id: number) => void
|
||||
}
|
||||
|
||||
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
|
||||
target_ips: binding.target_ips ?? [],
|
||||
target_cname: binding.target_cname ?? '',
|
||||
lb_mode: binding.lb_mode,
|
||||
health: {
|
||||
enabled: binding.health_check_enabled,
|
||||
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
||||
port: binding.health_check_port,
|
||||
path: binding.health_check_path,
|
||||
expected_status: binding.health_check_expected_status,
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: binding.health_check_verify_tls ?? false,
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers:
|
||||
binding.health_check_providers?.length > 0
|
||||
? binding.health_check_providers
|
||||
: [binding.health_check_provider ?? 'local'],
|
||||
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
}))
|
||||
}
|
||||
|
||||
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
return bindings
|
||||
.filter((binding) => {
|
||||
if (!binding.fqdn.trim()) return false
|
||||
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
|
||||
return binding.target_ips.length > 0
|
||||
})
|
||||
.map((binding) =>
|
||||
binding.record_type === 'CNAME'
|
||||
? {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_cname: binding.target_cname.trim(),
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health.enabled,
|
||||
health_check_type: binding.health.type,
|
||||
health_check_port: binding.health.port,
|
||||
health_check_path: binding.health.path,
|
||||
health_check_expected_status: binding.health.expected_status,
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_ips: binding.target_ips,
|
||||
target_ip_weights: binding.target_ip_weights,
|
||||
target_ip_priorities: binding.target_ip_priorities,
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health.enabled,
|
||||
health_check_type: binding.health.type,
|
||||
health_check_port: binding.health.port,
|
||||
health_check_path: binding.health.path,
|
||||
health_check_expected_status: binding.health.expected_status,
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
||||
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||
return {
|
||||
fqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [],
|
||||
target_cname: '',
|
||||
lb_mode: 'round_robin',
|
||||
health: { ...defaultHealth },
|
||||
target_ip_weights: {},
|
||||
target_ip_priorities: {},
|
||||
}
|
||||
}
|
||||
|
||||
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
|
||||
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
|
||||
return draft
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
target_ips: pool,
|
||||
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
|
||||
target_ip_priorities: Object.fromEntries(
|
||||
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
|
||||
),
|
||||
enabled: next.enabled,
|
||||
type: next.type,
|
||||
port: next.port,
|
||||
path: next.path,
|
||||
expected_status: next.expected_status,
|
||||
interval_sec: next.interval_sec,
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider,
|
||||
providers: next.providers,
|
||||
aggregate: next.aggregate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,9 +94,11 @@ export function ServiceEditSheet({
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||
const [ips, setIps] = useState<string[]>([])
|
||||
const [commonFqdn, setCommonFqdn] = useState('')
|
||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||
const [address, setAddress] = useState<AddressBlockState>(() => emptyAddressBlock())
|
||||
const [health, setHealth] = useState<BindingHealthConfig>(() => ({
|
||||
...DEFAULT_BINDING_HEALTH,
|
||||
}))
|
||||
const [lbMode, setLbMode] = useState<LbAndHealthConfig['lb_mode']>('round_robin')
|
||||
const [lbWeight, setLbWeight] = useState(1)
|
||||
const [lbPriority, setLbPriority] = useState(1)
|
||||
|
||||
@@ -232,6 +110,8 @@ export function ServiceEditSheet({
|
||||
[groups],
|
||||
)
|
||||
|
||||
// Reset only when the sheet opens or the service id changes.
|
||||
// Health polling replaces `service` by identity and would wipe unsaved settings.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && service) {
|
||||
@@ -240,10 +120,10 @@ export function ServiceEditSheet({
|
||||
setServiceGroupId(
|
||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||
)
|
||||
setIps(service.ips ?? [])
|
||||
const drafts = toBindingDrafts(service)
|
||||
setBindings(drafts)
|
||||
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
||||
setAddress(hydrateAddressBlock(drafts, service.ips ?? []))
|
||||
setHealth(drafts[0]?.health ?? { ...DEFAULT_BINDING_HEALTH })
|
||||
setLbMode(drafts[0]?.lb_mode ?? service.lb_mode ?? 'round_robin')
|
||||
setLbWeight(service.lb_weight ?? 1)
|
||||
setLbPriority(service.lb_priority ?? 1)
|
||||
return
|
||||
@@ -254,149 +134,40 @@ export function ServiceEditSheet({
|
||||
setServiceGroupId(
|
||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||
)
|
||||
setIps([])
|
||||
setCommonFqdn('')
|
||||
setBindings([])
|
||||
setAddress(emptyAddressBlock())
|
||||
setHealth({ ...DEFAULT_BINDING_HEALTH })
|
||||
setLbMode('round_robin')
|
||||
setLbWeight(1)
|
||||
setLbPriority(1)
|
||||
}
|
||||
}, [open, mode, service, defaultGroupId])
|
||||
}, [open, mode, service?.id, defaultGroupId])
|
||||
|
||||
const zoneHints = useMemo(
|
||||
() => knownDomains.map((domain) => domain.zone_name),
|
||||
[knownDomains],
|
||||
)
|
||||
|
||||
const extraBindings = bindings.slice(1)
|
||||
|
||||
function handleCommonFqdnChange(value: string) {
|
||||
setCommonFqdn(value)
|
||||
setBindings((current) => {
|
||||
if (current.length === 0) return current
|
||||
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
|
||||
})
|
||||
}
|
||||
|
||||
function handleAddExtraBinding() {
|
||||
setBindings((current) => {
|
||||
const extra = withPoolIps(emptyBindingDraft(), ips)
|
||||
if (current.length === 0) {
|
||||
return [emptyBindingDraft(commonFqdn), extra]
|
||||
}
|
||||
return [...current, extra]
|
||||
})
|
||||
}
|
||||
|
||||
function handleRemoveExtraBinding(extraIndex: number) {
|
||||
const index = extraIndex + 1
|
||||
setBindings((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleFqdnChange(index: number, fqdn: string) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
record_type: recordType,
|
||||
target_ips: recordType === 'A' ? item.target_ips : [],
|
||||
target_cname: recordType === 'CNAME' ? item.target_cname : '',
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function handleCnameChange(index: number, value: string) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleIpsChange(index: number, targetIps: string[]) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
target_ips: targetIps,
|
||||
target_ip_weights: Object.fromEntries(
|
||||
targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
|
||||
),
|
||||
target_ip_priorities: Object.fromEntries(
|
||||
targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||
return {
|
||||
enabled: next.enabled,
|
||||
type: next.type,
|
||||
port: next.port,
|
||||
path: next.path,
|
||||
expected_status: next.expected_status,
|
||||
interval_sec: next.interval_sec,
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider,
|
||||
providers: next.providers,
|
||||
aggregate: next.aggregate,
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
||||
const health = healthFromConfig(next)
|
||||
setBindings((current) => {
|
||||
if (current.length === 0) {
|
||||
return [
|
||||
{
|
||||
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
|
||||
lb_mode: next.lb_mode,
|
||||
health,
|
||||
},
|
||||
]
|
||||
}
|
||||
return current.map((item, index) =>
|
||||
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
|
||||
)
|
||||
})
|
||||
setLbMode(next.lb_mode)
|
||||
setHealth(healthFromConfig(next))
|
||||
}
|
||||
|
||||
const primaryHealthValue: LbAndHealthConfig = {
|
||||
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
|
||||
...(bindings[0]?.health ?? defaultHealth),
|
||||
lb_mode: lbMode,
|
||||
...health,
|
||||
}
|
||||
|
||||
function resolveServiceGroupId(): number | null {
|
||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||
}
|
||||
|
||||
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
|
||||
const trimmed = commonFqdn.trim()
|
||||
if (!trimmed) return current
|
||||
if (current.length === 0) {
|
||||
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
|
||||
}
|
||||
return current.map((item, index) => {
|
||||
if (index !== 0) return item
|
||||
return withPoolIps({ ...item, fqdn: trimmed }, ips)
|
||||
})
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const syncedBindings = syncCommonDomain(bindings)
|
||||
const domains = buildDomainsPayload(syncedBindings)
|
||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
||||
const ips = address.nodes.map((node) => node.ip)
|
||||
const domains = toDomainsPayload(address, {
|
||||
lb_mode: lbMode,
|
||||
health,
|
||||
})
|
||||
const normalizedFqdns = domains.map((item) => item.fqdn.trim().toLowerCase())
|
||||
const hasDuplicateFqdn =
|
||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||
if (hasDuplicateFqdn) {
|
||||
@@ -440,6 +211,7 @@ export function ServiceEditSheet({
|
||||
const canSubmit = isCreate
|
||||
? name.trim().length > 0 && slug.trim().length > 0
|
||||
: Boolean(service)
|
||||
const addressResetKey = `${mode}-${service?.id ?? 'new'}-${open ? 'open' : 'closed'}`
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -447,8 +219,8 @@ export function ServiceEditSheet({
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
|
||||
определяется автоматически.
|
||||
Общие FQDN на весь пул IP. У каждого адреса можно указать несколько доп.
|
||||
FQDN, в том числе wildcard.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -495,162 +267,30 @@ export function ServiceEditSheet({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-common-domain">
|
||||
Общий домен (FQDN)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-common-domain"
|
||||
className="font-mono"
|
||||
value={commonFqdn}
|
||||
placeholder={
|
||||
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
|
||||
}
|
||||
onChange={(e) => handleCommonFqdnChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||
<TaggedInput
|
||||
id="edit-service-ips"
|
||||
value={ips}
|
||||
onChange={setIps}
|
||||
placeholder="192.168.1.1"
|
||||
validate={isValidIpv4}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
|
||||
<ServiceAddressBlock
|
||||
key={addressResetKey}
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
zoneHints={zoneHints}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Health check</h3>
|
||||
<HealthCheckConfigFields
|
||||
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>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Доп. FQDN</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddExtraBinding}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{extraBindings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет дополнительных FQDN
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{extraBindings.map((binding, extraIndex) => {
|
||||
const index = extraIndex + 1
|
||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||
return (
|
||||
<Item
|
||||
key={`extra-binding-${index}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="items-stretch"
|
||||
>
|
||||
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{parsedZone ? (
|
||||
<Badge variant="outline" size="xs" className="font-mono">
|
||||
{parsedZone.zoneName}
|
||||
</Badge>
|
||||
) : binding.fqdn.trim() ? (
|
||||
<Badge variant="warning-light" size="xs">
|
||||
зона не найдена
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
FQDN
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-auto shrink-0"
|
||||
aria-label="Удалить FQDN"
|
||||
onClick={() => handleRemoveExtraBinding(extraIndex)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
|
||||
<Input
|
||||
id={`extra-fqdn-${index}`}
|
||||
className="font-mono"
|
||||
value={binding.fqdn}
|
||||
onChange={(event) =>
|
||||
handleFqdnChange(index, event.target.value)
|
||||
}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `api.${zoneHints[0]}`
|
||||
: 'api.ivx.su'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
items={[
|
||||
{ label: 'A (IP)', value: 'A' },
|
||||
{ label: 'CNAME', value: 'CNAME' },
|
||||
]}
|
||||
value={binding.record_type}
|
||||
onValueChange={(value) =>
|
||||
handleRecordTypeChange(
|
||||
index,
|
||||
(value ?? 'A') as 'A' | 'CNAME',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`extra-type-${index}`}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A (IP)</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{binding.record_type === 'CNAME' ? (
|
||||
<Input
|
||||
id={`extra-cname-${index}`}
|
||||
value={binding.target_cname}
|
||||
placeholder="mmsk.rkns.top"
|
||||
onChange={(event) =>
|
||||
handleCnameChange(index, event.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ServiceBindingIpInput
|
||||
id={`extra-ip-${index}`}
|
||||
value={binding.target_ips}
|
||||
pool={ips}
|
||||
onChange={(targetIps) =>
|
||||
handleIpsChange(index, targetIps)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
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'
|
||||
import type { ServiceCertificateRow, ServiceView } from '@/lib/schemas'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import {
|
||||
certKeys,
|
||||
checkServiceCertificates,
|
||||
patchBindingCertMonitoring,
|
||||
serviceCertificatesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@cfdm/ui/components/toggle-group'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
|
||||
|
||||
interface ServiceIpRow {
|
||||
id: string
|
||||
ip: string
|
||||
status: HealthStatus
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
weight: number
|
||||
priority: number
|
||||
latency_ms: number | null
|
||||
last_checked_at: string | null
|
||||
last_error: string | null
|
||||
colo: string | null
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
export interface ServiceFqdnRow {
|
||||
id: string
|
||||
fqdn: string
|
||||
zone_name: string
|
||||
target_ips: string[]
|
||||
binding_id: number
|
||||
domain_id: number
|
||||
}
|
||||
|
||||
interface ServiceNodeRow {
|
||||
id: string
|
||||
nodeId: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: HealthStatus
|
||||
weight: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'ip', label: 'IP' },
|
||||
{ id: 'fqdn', label: 'FQDN' },
|
||||
{ id: 'nodes', label: 'Ноды' },
|
||||
{ id: 'ssl', label: 'SSL' },
|
||||
] as const
|
||||
|
||||
const HEALTH_OPTIONS = [
|
||||
{ value: 'up', label: 'OK' },
|
||||
{ value: 'degraded', label: 'Slow' },
|
||||
{ value: 'down', label: 'Down' },
|
||||
{ value: 'unknown', label: '—' },
|
||||
]
|
||||
|
||||
function mapNodeHealth(status: string): HealthStatus {
|
||||
if (status === 'healthy' || status === 'up') return 'up'
|
||||
if (status === 'unhealthy' || status === 'down') return 'down'
|
||||
if (status === 'degraded') return 'degraded'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
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 ?? {}),
|
||||
) as Record<string, number>
|
||||
const priorities = Object.assign(
|
||||
{},
|
||||
...service.domains.map((domain) => domain.target_ip_priorities ?? {}),
|
||||
) as Record<string, number>
|
||||
const activeSet = new Set(service.active_ips)
|
||||
|
||||
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,
|
||||
enabled: service.ip_enabled[ip] !== false,
|
||||
active: activeSet.has(ip),
|
||||
weight: weights[ip] ?? 1,
|
||||
priority: priorities[ip] ?? 1,
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildFqdnRows(service: ServiceView): ServiceFqdnRow[] {
|
||||
return service.domains.map((domain) => ({
|
||||
id: String(domain.binding_id),
|
||||
fqdn: domain.fqdn,
|
||||
zone_name: domain.zone_name,
|
||||
target_ips: domain.target_ips ?? [],
|
||||
binding_id: domain.binding_id,
|
||||
domain_id: domain.domain_id,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildNodeRows(
|
||||
nodes: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
}>,
|
||||
probes: readonly HealthLogProbe[] = [],
|
||||
): ServiceNodeRow[] {
|
||||
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({
|
||||
icon,
|
||||
label,
|
||||
iconClassName,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
iconClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={iconClassName ?? 'text-muted-foreground'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</IconTile>
|
||||
<span className="truncate font-mono text-sm">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServiceDetailGridProps {
|
||||
service: ServiceView
|
||||
nodes: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
}>
|
||||
probes?: readonly HealthLogProbe[]
|
||||
togglingIp: string | null
|
||||
onToggleIp: (ip: string, enabled: boolean) => void
|
||||
onChangeIp: (row: ServiceFqdnRow) => void
|
||||
onChangeDomain: () => void
|
||||
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,
|
||||
onChangeDomain,
|
||||
onAddNode,
|
||||
onDeleteNode,
|
||||
isLoading = false,
|
||||
section,
|
||||
}: ServiceDetailGridProps) {
|
||||
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', ['']),
|
||||
])
|
||||
const [fqdnFilters, setFqdnFilters] = useState<Filter[]>(() => [
|
||||
createFilter('fqdn', 'contains', ['']),
|
||||
])
|
||||
const [nodeFilters, setNodeFilters] = useState<Filter[]>(() => [
|
||||
createFilter('address', 'contains', ['']),
|
||||
createFilter('health_status', 'is', ['']),
|
||||
])
|
||||
const [sslFilters, setSslFilters] = useState<Filter[]>(() => [
|
||||
createFilter('hostname', 'contains', ['']),
|
||||
])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const certQuery = useQuery(serviceCertificatesQueryOptions(service.id))
|
||||
const sslRows = certQuery.data ?? []
|
||||
|
||||
const patchCertMonitoring = useMutation({
|
||||
mutationFn: ({
|
||||
bindingId,
|
||||
mode,
|
||||
}: {
|
||||
bindingId: number
|
||||
mode: CertMonitoring
|
||||
}) => patchBindingCertMonitoring(bindingId, mode),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||
])
|
||||
toast.success('Режим проверки SSL обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось обновить режим SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const checkSsl = useMutation({
|
||||
mutationFn: () => checkServiceCertificates(service.id),
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||
])
|
||||
toast.success(
|
||||
result.checked > 0
|
||||
? `Проверено FQDN: ${result.checked}`
|
||||
: 'Нет FQDN для проверки SSL',
|
||||
)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось проверить SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const ipRows = useMemo(() => buildIpRows(service, probes), [service, probes])
|
||||
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
||||
const nodeRows = useMemo(() => buildNodeRows(nodes, probes), [nodes, probes])
|
||||
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
|
||||
|
||||
const tabs = TABS.map((entry) => ({
|
||||
...entry,
|
||||
count:
|
||||
entry.id === 'ip'
|
||||
? ipRows.length
|
||||
: entry.id === 'fqdn'
|
||||
? fqdnRows.length
|
||||
: entry.id === 'ssl'
|
||||
? sslRows.length
|
||||
: nodeRows.length,
|
||||
}))
|
||||
|
||||
const ipFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по IP…',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: HEALTH_OPTIONS,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const fqdnFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'fqdn',
|
||||
label: 'FQDN',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по FQDN…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const nodeFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'address',
|
||||
label: 'Адрес',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по адресу…',
|
||||
},
|
||||
{
|
||||
key: 'health_status',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: HEALTH_OPTIONS,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const sslFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'hostname',
|
||||
label: 'FQDN',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по FQDN…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ip',
|
||||
accessorKey: 'ip',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="IP" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<NetworkIcon />}
|
||||
label={row.original.ip}
|
||||
iconClassName="text-info"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: 'Health',
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge
|
||||
status={row.original.status}
|
||||
latencyMs={row.original.latency_ms}
|
||||
lastCheckedAt={row.original.last_checked_at}
|
||||
lastError={row.original.last_error}
|
||||
colo={row.original.colo}
|
||||
provider={row.original.provider}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Пул',
|
||||
cell: ({ row }) =>
|
||||
markActive && row.original.active ? (
|
||||
<StatusBadge status="active" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
accessorKey: 'weight',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Вес" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{service.lb_mode === 'weighted' ? `w${row.original.weight}` : row.original.weight}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Приоритет" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
header: 'Вкл',
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={row.original.enabled}
|
||||
disabled={togglingIp === row.original.ip}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleIp(row.original.ip, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
row.original.enabled
|
||||
? `Выключить IP ${row.original.ip}`
|
||||
: `Включить IP ${row.original.ip}`
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[markActive, onToggleIp, service.lb_mode, togglingIp],
|
||||
)
|
||||
|
||||
const fqdnColumns = useMemo<ColumnDef<ServiceFqdnRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'fqdn',
|
||||
accessorKey: 'fqdn',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<GlobeIcon />}
|
||||
label={row.original.fqdn}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'zone',
|
||||
accessorKey: 'zone_name',
|
||||
header: 'Зона',
|
||||
},
|
||||
{
|
||||
id: 'ips',
|
||||
header: 'Target IP',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{row.original.target_ips.join(', ') || '—'}
|
||||
</span>
|
||||
<Badge variant="outline" size="xs">
|
||||
{row.original.target_ips.length} IP
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChangeIp(row.original)}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onChangeIp],
|
||||
)
|
||||
|
||||
const nodeColumns = useMemo<ColumnDef<ServiceNodeRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'address',
|
||||
accessorKey: 'address',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Адрес" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<ServerIcon />}
|
||||
label={row.original.address}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
accessorKey: 'health_status',
|
||||
header: 'Health',
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge status={row.original.health_status} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
header: 'Вес / приоритет',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{row.original.protocol}
|
||||
{row.original.port ? `:${row.original.port}` : ''} · w
|
||||
{row.original.weight} · p{row.original.priority}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onDeleteNode(row.original.nodeId)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onDeleteNode],
|
||||
)
|
||||
|
||||
const sslColumns = useMemo<ColumnDef<ServiceCertificateRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<ShieldCheckIcon />}
|
||||
label={row.original.hostname}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'relative',
|
||||
header: 'Срок',
|
||||
cell: ({ row }) =>
|
||||
certRelativeBadge(row.original.status, row.original.expires_at),
|
||||
},
|
||||
{
|
||||
id: 'mode',
|
||||
header: 'Режим',
|
||||
cell: ({ row }) => (
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={[row.original.cert_monitoring]}
|
||||
onValueChange={(next) => {
|
||||
const value = Array.isArray(next) ? next[0] : next
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value === row.original.cert_monitoring
|
||||
) {
|
||||
return
|
||||
}
|
||||
patchCertMonitoring.mutate({
|
||||
bindingId: row.original.binding_id,
|
||||
mode: value as CertMonitoring,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<ToggleGroupItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_checked_at',
|
||||
accessorKey: 'last_checked_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Проверка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.last_checked_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[patchCertMonitoring],
|
||||
)
|
||||
|
||||
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={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={fqdnFilterFields}
|
||||
filters={fqdnFilters}
|
||||
onFiltersChange={setFqdnFilters}
|
||||
onClearFilters={() => setFqdnFilters([createFilter('fqdn', 'contains', [''])])}
|
||||
getFilterFieldValue={(item, field) =>
|
||||
field === 'fqdn' ? `${item.fqdn} ${item.zone_name}` : ''
|
||||
}
|
||||
columns={fqdnColumns}
|
||||
data={fqdnRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onChangeDomain}
|
||||
disabled={fqdnRows.length === 0}
|
||||
>
|
||||
Сменить домен
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === 'nodes') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={nodeFilterFields}
|
||||
filters={nodeFilters}
|
||||
onFiltersChange={setNodeFilters}
|
||||
onClearFilters={() =>
|
||||
setNodeFilters([
|
||||
createFilter('address', 'contains', ['']),
|
||||
createFilter('health_status', 'is', ['']),
|
||||
])
|
||||
}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field === 'address') return item.address
|
||||
if (field === 'health_status') return item.health_status
|
||||
return ''
|
||||
}}
|
||||
columns={nodeColumns}
|
||||
data={nodeRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === 'ssl') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title={section === 'ssl' ? 'SSL' : 'Активы сервиса'}
|
||||
description={
|
||||
section === 'ssl'
|
||||
? 'Сертификаты привязанных FQDN'
|
||||
: 'IP, FQDN, ноды и SSL этого сервиса'
|
||||
}
|
||||
{...sharedTabs}
|
||||
filterFields={sslFilterFields}
|
||||
filters={sslFilters}
|
||||
onFiltersChange={setSslFilters}
|
||||
onClearFilters={() =>
|
||||
setSslFilters([createFilter('hostname', 'contains', [''])])
|
||||
}
|
||||
getFilterFieldValue={(item, field) =>
|
||||
field === 'hostname' ? item.hostname : ''
|
||||
}
|
||||
columns={sslColumns}
|
||||
data={sslRows}
|
||||
getRowId={(row) => String(row.binding_id)}
|
||||
isLoading={isLoading || certQuery.isLoading}
|
||||
primaryAction={
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Проверить SSL"
|
||||
disabled={checkSsl.isPending || sslRows.length === 0}
|
||||
onClick={() => checkSsl.mutate()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon className="size-4.5" aria-hidden />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Проверить</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'Нет FQDN',
|
||||
description: 'Привяжите домен к сервису, чтобы мониторить SSL.',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={ipFilterFields}
|
||||
filters={ipFilters}
|
||||
onFiltersChange={setIpFilters}
|
||||
onClearFilters={() =>
|
||||
setIpFilters([
|
||||
createFilter('ip', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
])
|
||||
}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field === 'ip') return item.ip
|
||||
if (field === 'status') return item.status
|
||||
return ''
|
||||
}}
|
||||
columns={ipColumns}
|
||||
data={ipRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -49,6 +51,7 @@ import { cn } from '@cfdm/ui/lib/utils'
|
||||
* Preview: https://reui.io/preview/base/settings-8
|
||||
* Frame: https://reui.io/docs/components/base/frame
|
||||
* IconTile: https://reui.io/docs/components/base/icon-tile
|
||||
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
|
||||
*/
|
||||
|
||||
type LbMode = ServiceView['lb_mode']
|
||||
@@ -70,11 +73,39 @@ const LB_MODE_META: Record<
|
||||
weighted: {
|
||||
icon: ScaleIcon,
|
||||
className: 'text-info',
|
||||
label: 'Weighted (веса)',
|
||||
label: 'Веса (подмена IP)',
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -124,7 +155,7 @@ export function ServiceUnitCard({
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="h-full min-w-0">
|
||||
<FramePanel fit>
|
||||
<FramePanel fit className="bg-muted">
|
||||
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
|
||||
<ItemMedia>
|
||||
<IconTile
|
||||
@@ -147,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">
|
||||
@@ -239,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}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,8 @@ import { useEffect, useMemo } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
@@ -30,7 +28,6 @@ import {
|
||||
const subdomainEditSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите имя'),
|
||||
serviceId: z.string(),
|
||||
certMonitoring: z.enum(['auto', 'required', 'skipped']),
|
||||
})
|
||||
|
||||
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
|
||||
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
serviceId: 'none',
|
||||
certMonitoring: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
|
||||
[services, serviceGroupById],
|
||||
)
|
||||
|
||||
const certMonitoringItems = useMemo(
|
||||
() =>
|
||||
certMonitoringOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && subdomain) {
|
||||
form.reset({
|
||||
name: subdomain.name,
|
||||
serviceId: currentServiceId || 'none',
|
||||
certMonitoring: subdomain.cert_monitoring,
|
||||
})
|
||||
return
|
||||
}
|
||||
form.reset({
|
||||
name: '',
|
||||
serviceId: 'none',
|
||||
certMonitoring: 'auto',
|
||||
})
|
||||
}, [open, mode, subdomain, currentServiceId, form])
|
||||
|
||||
const certMonitoring = form.watch('certMonitoring')
|
||||
const certHint =
|
||||
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
|
||||
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
||||
|
||||
function handleSubmit(values: SubdomainEditValues) {
|
||||
onSubmit({
|
||||
name: values.name.trim(),
|
||||
serviceId: values.serviceId,
|
||||
certMonitoring: values.certMonitoring as CertMonitoring,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Мониторинг SSL"
|
||||
htmlFor="subdomain_cert_monitoring"
|
||||
hint={certHint}
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="certMonitoring"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange((value ?? 'auto') as CertMonitoring)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="subdomain_cert_monitoring"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
serviceGroupsQueryOptions,
|
||||
servicesQueryOptions,
|
||||
subdomainsListQueryOptions,
|
||||
updateDomain,
|
||||
updateSubdomain,
|
||||
} from '@/queries'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
@@ -172,21 +171,6 @@ export function useDomainPage(domainId: number) {
|
||||
},
|
||||
})
|
||||
|
||||
const updateDomainCertMonitoringMutation = useMutation({
|
||||
mutationFn: (certMonitoring: CertMonitoring) =>
|
||||
updateDomain(domainId, { cert_monitoring: certMonitoring }),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
void queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
toast.success('Режим мониторинга SSL обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteSubdomainMutation = useMutation({
|
||||
mutationFn: (id: number) => deleteSubdomain(id),
|
||||
onSuccess: () => {
|
||||
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
|
||||
syncMutation,
|
||||
createSubdomainMutation,
|
||||
updateSubdomainMutation,
|
||||
updateDomainCertMonitoringMutation,
|
||||
deleteSubdomainMutation,
|
||||
linkServiceMutation,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
|
||||
|
||||
describe('getBreadcrumbs', () => {
|
||||
it('keeps a single Настройки parent plus the active section', () => {
|
||||
expect(getBreadcrumbs('/settings/appearance')).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||
])
|
||||
expect(getBreadcrumbs('/settings/health')).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Health-check', href: '/settings/health' },
|
||||
])
|
||||
expect(getBreadcrumbs('/settings/integrations')).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Интеграции', href: '/settings/integrations' },
|
||||
])
|
||||
})
|
||||
|
||||
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)
|
||||
expect(new Set(hrefs).size).toBe(hrefs.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupeBreadcrumbs', () => {
|
||||
it('collapses stacked identical labels from repeated navigations', () => {
|
||||
expect(
|
||||
dedupeBreadcrumbs([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Настройки', href: '/settings/appearance' },
|
||||
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||
]),
|
||||
).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
export interface BreadcrumbCrumb {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/domains': 'Домены',
|
||||
'/groups': 'Группы доменов',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
}
|
||||
|
||||
const SETTINGS_SECTIONS: Record<string, string> = {
|
||||
'/settings/appearance': 'Внешний вид',
|
||||
'/settings/health': 'Health-check',
|
||||
'/settings/integrations': 'Интеграции',
|
||||
}
|
||||
|
||||
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
|
||||
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
|
||||
const out: BreadcrumbCrumb[] = []
|
||||
for (const crumb of crumbs) {
|
||||
const prev = out.at(-1)
|
||||
if (prev && prev.label === crumb.label) continue
|
||||
out.push(crumb)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function getBreadcrumbs(
|
||||
pathname: string,
|
||||
dynamicLabels: Record<string, string> = {},
|
||||
): BreadcrumbCrumb[] {
|
||||
const path = pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
if (path === '/') {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
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[servicePath] ?? 'Сервис', href: servicePath },
|
||||
...(section
|
||||
? [{ label: sectionLabels[section] ?? section, href: path }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
if (path.match(/^\/groups\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Группы доменов', href: '/groups' },
|
||||
{ label: dynamicLabels[path] ?? 'Группа', href: path },
|
||||
]
|
||||
}
|
||||
|
||||
if (path.match(/^\/domains\/\d+\/dns$/)) {
|
||||
const domainId = path.split('/')[2]
|
||||
const domainPath = `/domains/${domainId}`
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||
{ label: 'DNS', href: path },
|
||||
]
|
||||
}
|
||||
|
||||
if (path.match(/^\/domains\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
|
||||
]
|
||||
}
|
||||
|
||||
if (path === '/settings' || path.startsWith('/settings/')) {
|
||||
const section = SETTINGS_SECTIONS[path]
|
||||
return dedupeBreadcrumbs([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
...(section ? [{ label: section, href: path }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
const title = routeTitles[path]
|
||||
if (title) {
|
||||
return [{ label: title, href: path }]
|
||||
}
|
||||
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Авто',
|
||||
description: 'Проверять, если хост обслуживается активным сервисом',
|
||||
description: 'Проверять, если health-check сервиса с verify TLS',
|
||||
},
|
||||
{
|
||||
value: 'required',
|
||||
label: 'Обязательно',
|
||||
description: 'Всегда проверять SSL, даже без привязок',
|
||||
description: 'Всегда проверять SSL для этого FQDN',
|
||||
},
|
||||
{
|
||||
value: 'skipped',
|
||||
label: 'Не проверять',
|
||||
description: 'Исключить из мониторинга сертификатов',
|
||||
description: 'Исключить FQDN из мониторинга сертификатов',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user