Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa07f08684 | ||
|
|
2418344551 | ||
|
|
73f5380768 | ||
|
|
0ef57c673c | ||
|
|
b078fa0a03 | ||
|
|
75575a3243 | ||
|
|
5e2c301442 | ||
|
|
56cddedefe | ||
|
|
df5d5ef4ab | ||
|
|
de3dbe8521 | ||
|
|
7eb195c5d7 | ||
|
|
7f06466058 | ||
|
|
0d567379fa | ||
|
|
a228febc27 | ||
|
|
5cf39880f8 |
No files matched your search
@@ -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 }>,
|
||||
@@ -397,7 +397,7 @@ function applyAggregatedStatus(
|
||||
});
|
||||
}
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
await options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.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;
|
||||
@@ -9,8 +9,8 @@ export const WEIGHTED_DNS_TTL = 60;
|
||||
|
||||
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): 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;
|
||||
if (pool.length === 1) return [pool[0]!.ip];
|
||||
|
||||
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
|
||||
|
||||
@@ -30,7 +30,7 @@ import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||
import {
|
||||
canApplyLb,
|
||||
isHealthy,
|
||||
isPoolMember,
|
||||
isSharedPool,
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
@@ -287,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;
|
||||
}
|
||||
}
|
||||
@@ -313,16 +313,20 @@ function desiredAIps(
|
||||
scope === "binding"
|
||||
? getBindingLbState(db, refId)
|
||||
: getGroupLbState(db, refId);
|
||||
const serviceIps =
|
||||
// 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,
|
||||
state.rows,
|
||||
fallbackIps,
|
||||
activeRows,
|
||||
activeFallback,
|
||||
Date.now(),
|
||||
serviceIps,
|
||||
enabledIps,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,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);
|
||||
@@ -511,6 +546,34 @@ function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
|
||||
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[],
|
||||
@@ -532,6 +595,9 @@ function attachServiceHealth(
|
||||
),
|
||||
);
|
||||
const ip_health = (view.ips ?? []).map((ip) => {
|
||||
if (!isIpHealthMonitored(db, view, ip)) {
|
||||
return inactiveIpHealthRow(ip);
|
||||
}
|
||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||
const live = liveByIp.get(ip);
|
||||
const status = overlayLiveHealth(row?.status, live?.status);
|
||||
@@ -549,17 +615,26 @@ function attachServiceHealth(
|
||||
colo: extras?.colo ?? null,
|
||||
};
|
||||
});
|
||||
const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));
|
||||
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: overlayLiveHealth(health?.health_status, displayStatus),
|
||||
health_status:
|
||||
monitoredStatuses.length > 0
|
||||
? overlayLiveHealth(health?.health_status, displayStatus)
|
||||
: "unknown",
|
||||
health_latency_ms:
|
||||
displayStatus !== "unknown"
|
||||
monitoredStatuses.length > 0 && displayStatus !== "unknown"
|
||||
? (latencyRow?.latency_ms ?? null)
|
||||
: (health?.health_latency_ms ?? null),
|
||||
: null,
|
||||
ip_health,
|
||||
};
|
||||
});
|
||||
@@ -662,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,
|
||||
@@ -707,6 +767,72 @@ async function syncBindingDns(
|
||||
);
|
||||
}
|
||||
|
||||
/** 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(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -718,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) {
|
||||
@@ -795,6 +936,10 @@ async function syncBindingADns(
|
||||
): 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) {
|
||||
@@ -1129,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1653,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)) {
|
||||
|
||||
@@ -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,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"]);
|
||||
});
|
||||
});
|
||||
@@ -376,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
@@ -412,6 +413,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
{ 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",
|
||||
@@ -450,6 +452,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
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",
|
||||
@@ -478,4 +481,74 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -70,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",
|
||||
@@ -105,6 +117,17 @@ describe("selectActiveIpsByMode", () => {
|
||||
).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),
|
||||
@@ -126,7 +149,7 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("round_robin excludes unknown when another ip is up", () => {
|
||||
it("round_robin puts recovering unknown back with live ips", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
@@ -135,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", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import {
|
||||
listGroupViews,
|
||||
toggleServiceIp,
|
||||
updateConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
|
||||
@@ -250,7 +251,7 @@ describe("create service then list groups", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -280,6 +281,18 @@ describe("create service then list groups", () => {
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
const domainPayload = {
|
||||
lb_mode: "round_robin" as const,
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp" as const,
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
};
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4", "5.6.7.8"],
|
||||
service_group_id: group.id,
|
||||
@@ -289,23 +302,81 @@ describe("create service then list groups", () => {
|
||||
target_ips: ["1.2.3.4", "5.6.7.8"],
|
||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
...domainPayload,
|
||||
},
|
||||
{
|
||||
fqdn: "extra.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
target_ip_weights: { "1.2.3.4": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1 },
|
||||
...domainPayload,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// HTTP toggle uses request.server.cf; disable DNS push so the test
|
||||
// does not call the real Cloudflare client.
|
||||
repos.setServiceEnabled(app.db, created.id, false);
|
||||
const commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "panel")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
|
||||
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
|
||||
|
||||
expect(repos.listServiceIps(app.db, created.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(false);
|
||||
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
// DNS for common FQDN drops the disabled IP only.
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["5.6.7.8"]);
|
||||
// Per-IP extra FQDN has no enabled targets → A records removed.
|
||||
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
|
||||
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(true);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, extraBinding.id)
|
||||
.map((r) => r.content),
|
||||
).toEqual(["1.2.3.4"]);
|
||||
|
||||
// HTTP toggle still updates ip_enabled without mutating bindings.
|
||||
repos.setServiceEnabled(app.db, created.id, false);
|
||||
const offRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||
@@ -319,24 +390,117 @@ describe("create service then list groups", () => {
|
||||
};
|
||||
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
||||
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
||||
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
|
||||
|
||||
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
|
||||
|
||||
const onRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||
headers,
|
||||
payload: { ip: "1.2.3.4", enabled: true },
|
||||
});
|
||||
expect(onRes.statusCode).toBe(200);
|
||||
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
|
||||
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
|
||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const cf = mockCf();
|
||||
|
||||
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||
const group = repos.createServiceGroup(
|
||||
app.db,
|
||||
"VPN",
|
||||
"vpn-repair",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
);
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/services",
|
||||
headers,
|
||||
payload: {
|
||||
name: "Repair",
|
||||
slug: "panel-ip-repair",
|
||||
service_group_id: group.id,
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||
service_group_id: group.id,
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
},
|
||||
{
|
||||
fqdn: "extra.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
target_ip_weights: { "1.2.3.4": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1 },
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "gw")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
|
||||
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
|
||||
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
|
||||
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
||||
{ ip: "5.6.7.8", weight: 1, priority: 1 },
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
]);
|
||||
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${created.id}`,
|
||||
headers,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
const view = getRes.json() as {
|
||||
domains: Array<{ fqdn: string; target_ips: string[] }>;
|
||||
};
|
||||
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
|
||||
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
|
||||
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
|
||||
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
"9.9.9.9",
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServiceBindingView } from "@cfdm/shared";
|
||||
import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js";
|
||||
import {
|
||||
cfdmBindingSyncItemSchema,
|
||||
type ServiceBindingView,
|
||||
} from "@cfdm/shared";
|
||||
import {
|
||||
resolveBindingIpsForSync,
|
||||
resolveLbModeForSync,
|
||||
effectiveLbModeForSync,
|
||||
} from "../src/services/vps-tracker-sync.js";
|
||||
|
||||
function binding(
|
||||
partial: Partial<ServiceBindingView> &
|
||||
@@ -160,3 +167,71 @@ describe("resolveBindingIpsForSync", () => {
|
||||
expect(ips).toEqual(["203.0.113.55"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLbModeForSync", () => {
|
||||
it("prefers binding lb_mode", () => {
|
||||
expect(resolveLbModeForSync("failover", "round_robin")).toBe("failover");
|
||||
});
|
||||
|
||||
it("falls back to service group lb_mode", () => {
|
||||
expect(resolveLbModeForSync("off", "weighted")).toBe("weighted");
|
||||
expect(resolveLbModeForSync(undefined, "round_robin")).toBe("round_robin");
|
||||
});
|
||||
|
||||
it("returns undefined when neither is a known mode", () => {
|
||||
expect(resolveLbModeForSync("off", "off")).toBeUndefined();
|
||||
expect(resolveLbModeForSync(null, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveLbModeForSync", () => {
|
||||
it("omits mode when unique origin IPs are below two", () => {
|
||||
expect(
|
||||
effectiveLbModeForSync("round_robin", "failover", ["203.0.113.10"]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
effectiveLbModeForSync("round_robin", "failover", [
|
||||
"203.0.113.10",
|
||||
"203.0.113.10",
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits configured mode when the service has a pool", () => {
|
||||
expect(
|
||||
effectiveLbModeForSync("failover", "round_robin", [
|
||||
"203.0.113.10",
|
||||
"203.0.113.20",
|
||||
]),
|
||||
).toBe("failover");
|
||||
expect(
|
||||
effectiveLbModeForSync("off", "weighted", [
|
||||
"203.0.113.10",
|
||||
"198.51.100.1",
|
||||
]),
|
||||
).toBe("weighted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cfdmBindingSyncItemSchema lbMode", () => {
|
||||
const base = {
|
||||
bindingId: 1,
|
||||
serviceId: 10,
|
||||
serviceName: "VPN",
|
||||
serviceSlug: "vpn",
|
||||
fqdn: "vpn.example.com",
|
||||
zoneName: "example.com",
|
||||
hostname: "vpn",
|
||||
ips: ["203.0.113.10"],
|
||||
};
|
||||
|
||||
it("accepts optional lbMode on sync payload", () => {
|
||||
const parsed = cfdmBindingSyncItemSchema.parse({ ...base, lbMode: "failover" });
|
||||
expect(parsed.lbMode).toBe("failover");
|
||||
});
|
||||
|
||||
it("accepts payload without lbMode (legacy)", () => {
|
||||
const parsed = cfdmBindingSyncItemSchema.parse(base);
|
||||
expect(parsed.lbMode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
GlobeIcon,
|
||||
ChevronRightIcon,
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
PinIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import { useSystemAttention } from '@/hooks/use-system-attention'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@cfdm/ui/components/collapsible'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -18,25 +28,114 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
|
||||
const infrastructureNav = [
|
||||
const PINNED_KEY = 'cfdm.sidebar.pinned'
|
||||
const COLLAPSED_KEY = 'cfdm.sidebar.collapsed'
|
||||
|
||||
type NavAttention = 'services-down' | 'certs-expiring'
|
||||
|
||||
type AppNavTo =
|
||||
| '/'
|
||||
| '/domains'
|
||||
| '/services'
|
||||
| '/groups'
|
||||
| '/certificates'
|
||||
| '/settings/appearance'
|
||||
|
||||
type NavItem = {
|
||||
to: AppNavTo
|
||||
label: string
|
||||
icon: typeof GlobeIcon
|
||||
exact: boolean
|
||||
matchPrefix?: string
|
||||
attention?: NavAttention
|
||||
}
|
||||
|
||||
const infrastructureNav: NavItem[] = [
|
||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||
] as const
|
||||
]
|
||||
|
||||
const operationsNav = [
|
||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||
] as const
|
||||
const operationsNav: NavItem[] = [
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
icon: ServerIcon,
|
||||
exact: false,
|
||||
attention: 'services-down',
|
||||
},
|
||||
{
|
||||
to: '/settings/appearance',
|
||||
label: 'Настройки',
|
||||
icon: SettingsIcon,
|
||||
exact: false,
|
||||
matchPrefix: '/settings',
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryNav = [
|
||||
const secondaryNav: NavItem[] = [
|
||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
icon: ShieldCheckIcon,
|
||||
exact: false,
|
||||
attention: 'certs-expiring',
|
||||
},
|
||||
]
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{ id: 'infrastructure', label: 'Инфраструктура', items: infrastructureNav },
|
||||
{ id: 'operations', label: 'Операции', items: operationsNav },
|
||||
{ id: 'secondary', label: 'Прочее', items: secondaryNav },
|
||||
] as const
|
||||
|
||||
const NAV_ITEMS = NAV_GROUPS.flatMap((group) => group.items)
|
||||
|
||||
function readStringList(key: string) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((item): item is string => typeof item === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeStringList(key: string, values: string[]) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(values))
|
||||
} catch {
|
||||
// хранилище недоступно — состояние остаётся в памяти
|
||||
}
|
||||
}
|
||||
|
||||
function useStringList(key: string) {
|
||||
const [values, setValues] = useState<string[]>(() => readStringList(key))
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string) => {
|
||||
setValues((current) => {
|
||||
const next = current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id]
|
||||
writeStringList(key, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[key],
|
||||
)
|
||||
|
||||
return { values, toggle }
|
||||
}
|
||||
|
||||
function isNavActive(
|
||||
pathname: string,
|
||||
to: string,
|
||||
@@ -50,56 +149,148 @@ function isNavActive(
|
||||
return pathname === to || pathname.startsWith(`${to}/`)
|
||||
}
|
||||
|
||||
function NavItems({
|
||||
items,
|
||||
pathname,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
servicesDown,
|
||||
certsExpiring,
|
||||
}: {
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
pinned: readonly string[]
|
||||
onTogglePin: (to: string) => void
|
||||
servicesDown: number
|
||||
certsExpiring: number
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isPinned = pinned.includes(item.to)
|
||||
const showServicesDot = item.attention === 'services-down' && servicesDown > 0
|
||||
const showCertsDot = item.attention === 'certs-expiring' && certsExpiring > 0
|
||||
const showDot = showServicesDot || showCertsDot
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={isNavActive(pathname, item.to, item.exact, item.matchPrefix)}
|
||||
render={<Link to={item.to} activeOptions={{ exact: item.exact }} />}
|
||||
>
|
||||
<item.icon />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{showDot ? (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto size-1.5 shrink-0 rounded-full group-data-[collapsible=icon]:hidden',
|
||||
showServicesDot ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{showDot ? (
|
||||
<span className="sr-only">
|
||||
{showServicesDot ? ', есть недоступные' : ', истекают в ближайшие 14 дней'}
|
||||
</span>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
{showDot ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-1 right-1 hidden size-1.5 rounded-full group-data-[collapsible=icon]:block',
|
||||
showServicesDot ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<SidebarMenuAction
|
||||
showOnHover={!isPinned}
|
||||
aria-label={isPinned ? `Открепить «${item.label}»` : `Закрепить «${item.label}»`}
|
||||
onClick={() => onTogglePin(item.to)}
|
||||
>
|
||||
<PinIcon />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function NavSection({
|
||||
id,
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
collapsed,
|
||||
forceOpen,
|
||||
onToggleCollapsed,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
servicesDown,
|
||||
certsExpiring,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
items: readonly {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof GlobeIcon
|
||||
exact: boolean
|
||||
matchPrefix?: string
|
||||
}[]
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
collapsed: boolean
|
||||
forceOpen: boolean
|
||||
onToggleCollapsed: (id: string) => void
|
||||
pinned: readonly string[]
|
||||
onTogglePin: (to: string) => void
|
||||
servicesDown: number
|
||||
certsExpiring: number
|
||||
}) {
|
||||
const open = forceOpen || !collapsed
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={isNavActive(
|
||||
pathname,
|
||||
item.to,
|
||||
item.exact,
|
||||
item.matchPrefix,
|
||||
)}
|
||||
render={
|
||||
<Link
|
||||
to={item.to}
|
||||
activeOptions={{ exact: item.exact }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (forceOpen) return
|
||||
if (nextOpen === open) return
|
||||
onToggleCollapsed(id)
|
||||
}}
|
||||
className="group/nav-section"
|
||||
>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="w-full" render={<CollapsibleTrigger />}>
|
||||
<span>{label}</span>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'ml-auto transition-transform duration-200 motion-reduce:transition-none',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<NavItems
|
||||
items={items}
|
||||
pathname={pathname}
|
||||
pinned={pinned}
|
||||
onTogglePin={onTogglePin}
|
||||
servicesDown={servicesDown}
|
||||
certsExpiring={certsExpiring}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { state, isMobile } = useSidebar()
|
||||
const attention = useSystemAttention()
|
||||
const pins = useStringList(PINNED_KEY)
|
||||
const collapsed = useStringList(COLLAPSED_KEY)
|
||||
const forceOpen = state === 'collapsed' && !isMobile
|
||||
const pinnedItems = pins.values
|
||||
.map((to) => NAV_ITEMS.find((item) => item.to === to))
|
||||
.filter((item): item is NavItem => item != null)
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -107,9 +298,37 @@ export function AppSidebar() {
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
||||
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
||||
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
|
||||
{pinnedItems.length > 0 ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<NavItems
|
||||
items={pinnedItems}
|
||||
pathname={pathname}
|
||||
pinned={pins.values}
|
||||
onTogglePin={pins.toggle}
|
||||
servicesDown={attention.servicesDown}
|
||||
certsExpiring={attention.certsExpiring}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<NavSection
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
label={group.label}
|
||||
items={group.items}
|
||||
pathname={pathname}
|
||||
collapsed={collapsed.values.includes(group.id)}
|
||||
forceOpen={forceOpen}
|
||||
onToggleCollapsed={collapsed.toggle}
|
||||
pinned={pins.values}
|
||||
onTogglePin={pins.toggle}
|
||||
servicesDown={attention.servicesDown}
|
||||
certsExpiring={attention.certsExpiring}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Frame } from "@/components/reui/frame"
|
||||
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { ICard } from "./data"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<a
|
||||
href={card.href}
|
||||
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full w-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
|
||||
>
|
||||
<FramePanel className="space-y-7.5 p-3.5">
|
||||
{card.icon}
|
||||
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
|
||||
{card.title}
|
||||
</h3>
|
||||
</FramePanel>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { ActivityIcon, BookOpenIcon, ShieldIcon, CircleDollarSignIcon, BarChart3Icon, TrendingUp } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
title: string
|
||||
href: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
title: "Valuation",
|
||||
href: "#valuation",
|
||||
icon: (
|
||||
<ActivityIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Ledger",
|
||||
href: "#ledger",
|
||||
icon: (
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Funding",
|
||||
href: "#funding",
|
||||
icon: (
|
||||
<ShieldIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Cash Flow",
|
||||
href: "#cash-flow",
|
||||
icon: (
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Metrics",
|
||||
href: "#metrics",
|
||||
icon: (
|
||||
<BarChart3Icon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Earnings",
|
||||
href: "#earnings",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-lg items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type ToolbarOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
export const EXPORT_AUDIENCE_OPTIONS = [
|
||||
{ value: "everyone", label: "Everyone" },
|
||||
{ value: "ops-leads", label: "Ops leads" },
|
||||
{ value: "finance-reviewers", label: "Finance reviewers" },
|
||||
{ value: "client-owners", label: "Client owners" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_SCOPE_OPTIONS = [
|
||||
{ value: "all-workspaces", label: "All workspaces" },
|
||||
{ value: "harbor-field", label: "Harbor field" },
|
||||
{ value: "market-lab", label: "Market lab" },
|
||||
{ value: "support-desk", label: "Support desk" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_RANGE_OPTIONS = [
|
||||
{ value: "last-30-days", label: "Last 30 days" },
|
||||
{ value: "this-quarter", label: "This quarter" },
|
||||
{ value: "previous-cycle", label: "Previous cycle" },
|
||||
{ value: "custom-window", label: "Custom window" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_FORMAT_OPTIONS = [
|
||||
{
|
||||
value: "csv",
|
||||
label: "CSV bundle",
|
||||
description: "Spreadsheet-ready activity rows",
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: "PDF brief",
|
||||
description: "A concise review packet for stakeholders",
|
||||
},
|
||||
{
|
||||
value: "schedule",
|
||||
label: "Schedule delivery",
|
||||
description: "Send this export every Friday morning",
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
|
||||
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
|
||||
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
|
||||
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
} from "@cfdm/ui/components/button-group"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@cfdm/ui/components/empty"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
EXPORT_AUDIENCE_OPTIONS,
|
||||
EXPORT_FORMAT_OPTIONS,
|
||||
EXPORT_RANGE_OPTIONS,
|
||||
EXPORT_SCOPE_OPTIONS,
|
||||
type ExportAudience,
|
||||
type ExportFormat,
|
||||
type ExportRange,
|
||||
type ExportScope,
|
||||
type ToolbarOption,
|
||||
} from "./data"
|
||||
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
type ToolbarFilterProps<T extends string> = {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly ToolbarOption<T>[]
|
||||
onValueChange: (value: T) => void
|
||||
}
|
||||
|
||||
function getOptionLabel<T extends string>(
|
||||
options: readonly ToolbarOption<T>[],
|
||||
value: T
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function ToolbarFilter<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
}: ToolbarFilterProps<T>) {
|
||||
const selectedLabel = getOptionLabel(options, value)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`${label}: ${selectedLabel}`}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue !== null) {
|
||||
onValueChange(nextValue as T)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
closeOnClick
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
const [audience, setAudience] = useState<ExportAudience>(
|
||||
EXPORT_AUDIENCE_OPTIONS[0].value
|
||||
)
|
||||
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
|
||||
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
|
||||
|
||||
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
|
||||
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
|
||||
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
|
||||
|
||||
const showExportToast = (format: ExportFormat = "csv") => {
|
||||
const formatOption =
|
||||
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
|
||||
EXPORT_FORMAT_OPTIONS[0]
|
||||
|
||||
toast.message(`${formatOption.label} is ready to wire`, {
|
||||
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-[430px] w-full max-w-4xl flex-col"
|
||||
aria-labelledby="export-ledger-heading"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
{/* Heading */}
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{/* Title and Description */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* Title */}
|
||||
<h2
|
||||
id="export-ledger-heading"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
Activity Exports
|
||||
</h2>
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Download scoped activity packets for billing review, staffing
|
||||
audits, and client handoffs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ToolbarFilter
|
||||
label="Audience"
|
||||
value={audience}
|
||||
options={EXPORT_AUDIENCE_OPTIONS}
|
||||
onValueChange={setAudience}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Workspace"
|
||||
value={scope}
|
||||
options={EXPORT_SCOPE_OPTIONS}
|
||||
onValueChange={setScope}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Date range"
|
||||
value={range}
|
||||
options={EXPORT_RANGE_OPTIONS}
|
||||
onValueChange={setRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Action */}
|
||||
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none"
|
||||
onClick={() => showExportToast()}
|
||||
>
|
||||
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
|
||||
<ButtonGroupSeparator className="bg-primary/72" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="border-primary-foreground/20 rounded-l-none border-l"
|
||||
aria-label="Open download options"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => showExportToast("csv")}>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
CSV bundle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
PDF brief
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
|
||||
<CalendarClockIcon aria-hidden="true" />
|
||||
Schedule delivery
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
|
||||
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden="true">
|
||||
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
|
||||
{/* Empty State Content */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
No exportable activity yet
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||
Capture approved activity and this view will assemble your next
|
||||
review packet.
|
||||
</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Activity exports empty state
|
||||
</h1>
|
||||
<EmptyState />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import { FieldLegend, FieldSet } from "@cfdm/ui/components/field"
|
||||
import { ACCENT_COLORS } from "./data"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
// ── Color Picker ──
|
||||
|
||||
export function ColorPicker() {
|
||||
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||
|
||||
return (
|
||||
<FieldSet className="gap-0">
|
||||
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
aria-label={color.name}
|
||||
onClick={() => setSelected(color.value)}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||
selected === color.value &&
|
||||
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
{selected === color.value ? (
|
||||
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type Preference = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
}
|
||||
|
||||
export type AccentColorOption = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingBadgeVariant =
|
||||
| "primary-light"
|
||||
| "destructive-light"
|
||||
| "info-light"
|
||||
|
||||
export type SettingFieldProps = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: { label: string; variant: SettingBadgeVariant }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
}
|
||||
|
||||
export type DateFormatOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const DAYS: SelectOption[] = [
|
||||
{ value: "monday", label: "Monday" },
|
||||
{ value: "tuesday", label: "Tuesday" },
|
||||
{ value: "wednesday", label: "Wednesday" },
|
||||
{ value: "thursday", label: "Thursday" },
|
||||
{ value: "friday", label: "Friday" },
|
||||
{ value: "saturday", label: "Saturday" },
|
||||
{ value: "sunday", label: "Sunday" },
|
||||
]
|
||||
|
||||
export const CURRENCIES: SelectOption[] = [
|
||||
{ value: "usd", label: "USD · US Dollar" },
|
||||
{ value: "eur", label: "EUR · Euro" },
|
||||
{ value: "gbp", label: "GBP · British Pound" },
|
||||
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||
]
|
||||
|
||||
export const REGIONS: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "eu", label: "European Union" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "jp", label: "Japan" },
|
||||
{ value: "au", label: "Australia" },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||
{ name: "Slate", value: "#64748b" },
|
||||
{ name: "Rose", value: "#f43f5e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Emerald", value: "#10b981" },
|
||||
{ name: "Sky", value: "#0ea5e9" },
|
||||
]
|
||||
|
||||
export const PREFERENCES: Preference[] = [
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
label: "Enable keyboard shortcuts.",
|
||||
description: "Use shortcuts to speed up your workflow.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "spellcheck",
|
||||
label: "Auto spell-check.",
|
||||
description: "Highlight spelling errors in text fields.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "compact-mode",
|
||||
label: "Compact Mode.",
|
||||
description: "Reduce spacing for a denser interface layout.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "animations",
|
||||
label: "Reduce animations.",
|
||||
description: "Minimize motion effects throughout the UI.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
|
||||
import { PREFERENCES } from "./data"
|
||||
|
||||
// ── Editor Preferences ──
|
||||
|
||||
export function EditorPreferences() {
|
||||
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">
|
||||
Configure editing behavior and interface density.
|
||||
</FieldDescription>
|
||||
|
||||
{/* List */}
|
||||
<FieldGroup className="gap-3">
|
||||
{PREFERENCES.map((pref) => (
|
||||
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||
<Switch
|
||||
id={`settings-3-${pref.id}`}
|
||||
checked={values[pref.id]}
|
||||
onCheckedChange={(value) =>
|
||||
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
|
||||
<FieldContent className="gap-0.5">
|
||||
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||
{pref.label}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{pref.description}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@cfdm/ui/components/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@cfdm/ui/components/toggle-group"
|
||||
import { ColorPicker } from "./color-picker"
|
||||
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||
import { EditorPreferences } from "./editor-preferences"
|
||||
import { SettingField } from "./setting-field"
|
||||
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export function GeneralSettings() {
|
||||
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||
const [region, setRegion] = useState("us")
|
||||
const [currency, setCurrency] = useState("usd")
|
||||
const handleStartOfWeekChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setStartOfWeek(value)
|
||||
}
|
||||
}
|
||||
const handleRegionChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setRegion(value)
|
||||
}
|
||||
}
|
||||
const handleCurrencyChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setCurrency(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-3xl">
|
||||
<FrameHeader className="px-2! py-2.5!">
|
||||
<FrameTitle>General Settings</FrameTitle>
|
||||
<FrameDescription>Core app preferences.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
{/* ── Project name ── */}
|
||||
<SettingField
|
||||
title="Project name"
|
||||
description="The display name for your project across the platform."
|
||||
labelFor="settings-3-project-name"
|
||||
>
|
||||
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||
</SettingField>
|
||||
|
||||
{/* ── API endpoint ── */}
|
||||
<SettingField
|
||||
title="API endpoint"
|
||||
description="Set the base URL for your project API."
|
||||
badge={{ label: "Required", variant: "destructive-light" }}
|
||||
labelFor="settings-3-api-endpoint"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-3-api-endpoint"
|
||||
defaultValue="api.acme.io"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Start of week ── */}
|
||||
<SettingField
|
||||
title="Start of week"
|
||||
description="Choose which day marks the start of your week."
|
||||
labelFor="settings-3-start-of-week"
|
||||
>
|
||||
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DAYS.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Allowed origins ── */}
|
||||
<SettingField
|
||||
title="Allowed origins"
|
||||
description="Define the trusted domains for CORS requests."
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add origin
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<UploadIcon aria-hidden="true" />
|
||||
Import from file
|
||||
</Button>
|
||||
</div>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Accent color ── */}
|
||||
<SettingField
|
||||
title="Accent color"
|
||||
description="Select a color to represent your brand."
|
||||
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||
>
|
||||
<ColorPicker />
|
||||
</SettingField>
|
||||
|
||||
{/* ── Region & currency ── */}
|
||||
<SettingField
|
||||
title="Region & currency"
|
||||
description="Adjust your regional preferences and currency."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Regional and financial formatting preferences.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||
<Select value={region} onValueChange={handleRegionChange}>
|
||||
<SelectTrigger id="settings-3-region" className="w-full">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(REGIONS, region)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-currency">
|
||||
Currency
|
||||
</FieldLabel>
|
||||
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(CURRENCIES, currency)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.value}
|
||||
value={currency.value}
|
||||
>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Date display format ── */}
|
||||
<SettingField
|
||||
title="Date display format"
|
||||
description="Choose your preferred format for dates."
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setDateFormat(value)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Date display format"
|
||||
>
|
||||
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Editor preferences ── */}
|
||||
<SettingField
|
||||
title="Editor preferences"
|
||||
description="Adjust your editing environment and display options."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<EditorPreferences />
|
||||
</SettingField>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save changes</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
function getOptionLabel(
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@cfdm/ui/components/field"
|
||||
|
||||
interface SettingFieldProps {
|
||||
title: string
|
||||
description: string
|
||||
badge?: {
|
||||
label: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
// ── Setting Field ──
|
||||
|
||||
export function SettingField({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
}: SettingFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralSettings } from "./components/general-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface CardData {
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
value: string | number
|
||||
label: string
|
||||
info: ReactNode
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const cards: CardData[] = [
|
||||
{
|
||||
icon: (
|
||||
<Headphones aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-blue-600 dark:text-blue-400",
|
||||
value: 320,
|
||||
label: "Support Tickets",
|
||||
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-emerald-600 dark:text-emerald-400",
|
||||
value: "98%",
|
||||
label: "Resolved",
|
||||
info: <Badge variant="success-light">+2.1% this month</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<SmileIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-amber-600 dark:text-amber-400",
|
||||
value: "4.8",
|
||||
label: "Satisfaction Rate",
|
||||
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||
|
||||
import { cards } from "./data"
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<div className="@container w-full grow">
|
||||
{/* Grid */}
|
||||
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
|
||||
{cards.map((card, i) => (
|
||||
<Frame key={i}>
|
||||
<FramePanel className="flex flex-col items-start gap-6">
|
||||
<Item
|
||||
className={cn(
|
||||
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{card.info}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stats } from "./components/stats"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Stats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ interface ConfirmDialogProps {
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/** Кнопка подтверждения. Удаление остаётся destructive. */
|
||||
confirmVariant?: 'default' | 'destructive'
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -31,6 +33,7 @@ export function ConfirmDialog({
|
||||
description,
|
||||
confirmLabel = 'Удалить',
|
||||
cancelLabel = 'Отмена',
|
||||
confirmVariant = 'destructive',
|
||||
onConfirm,
|
||||
disabled,
|
||||
}: ConfirmDialogProps) {
|
||||
@@ -46,7 +49,11 @@ export function ConfirmDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
<AlertDialogAction
|
||||
variant={confirmVariant}
|
||||
onClick={onConfirm}
|
||||
disabled={disabled}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { FolderTreeIcon, GlobeIcon, Link2Icon, ServerIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
SubdomainEditSheet,
|
||||
type SubdomainEditValues,
|
||||
} from '@/components/subdomain-edit-sheet'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { useDomainPage } from '@/hooks/use-domain-page'
|
||||
import {
|
||||
type DomainDetailTab,
|
||||
} from '@/components/domains/domain-detail-tabs'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
import {
|
||||
dnsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
groupKeys,
|
||||
domainKeys,
|
||||
updateDomain,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
|
||||
const groupFormSchema = z.object({
|
||||
group_id: z.string(),
|
||||
})
|
||||
|
||||
type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||
|
||||
interface DomainDetailActions {
|
||||
openCreateSubdomain: () => void
|
||||
}
|
||||
|
||||
function DomainChromeSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TableSkeleton rows={1} cols={2} />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Общая шапка, KPI и полоса вкладок карточки домена.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Preview: https://reui.io/preview/base/form-7
|
||||
* Preview: https://reui.io/preview/base/settings-16
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function DomainDetailChrome({
|
||||
domainId,
|
||||
activeTab,
|
||||
children,
|
||||
}: {
|
||||
domainId: string
|
||||
activeTab: DomainDetailTab
|
||||
children: (actions: DomainDetailActions) => ReactNode
|
||||
}) {
|
||||
const id = Number(domainId)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const page = useDomainPage(id)
|
||||
const groupsQuery = useQuery(groupsQueryOptions())
|
||||
const dnsQuery = useQuery(dnsListQueryOptions(id))
|
||||
|
||||
const [groupOpen, setGroupOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [changeIp, setChangeIp] = useState<ServiceBinding | null>(null)
|
||||
|
||||
const domain = page.domain
|
||||
const groupForm = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupFormSchema),
|
||||
defaultValues: { group_id: 'none' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupOpen || !domain) return
|
||||
groupForm.reset({
|
||||
group_id: domain.group_id != null ? String(domain.group_id) : 'none',
|
||||
})
|
||||
}, [groupOpen, domain, groupForm])
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
if (!domain?.group_id) return null
|
||||
return groupsQuery.data?.find((group) => group.id === domain.group_id)?.name ?? null
|
||||
}, [domain, groupsQuery.data])
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
{ label: 'Без группы', value: 'none' },
|
||||
...(groupsQuery.data ?? []).map((group) => ({
|
||||
label: group.name,
|
||||
value: String(group.id),
|
||||
})),
|
||||
],
|
||||
[groupsQuery.data],
|
||||
)
|
||||
|
||||
const updateGroupMutation = useMutation({
|
||||
mutationFn: (groupId: number | null) => updateDomain(id, { group_id: groupId }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.detail(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.all }),
|
||||
])
|
||||
setGroupOpen(false)
|
||||
toast.success('Группа домена обновлена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сменить группу')
|
||||
},
|
||||
})
|
||||
|
||||
function openCreateSubdomain() {
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
async function handleCreateSubdomain(values: SubdomainEditValues) {
|
||||
await page.createSubdomainMutation.mutateAsync(values.name)
|
||||
setCreateOpen(false)
|
||||
}
|
||||
|
||||
function handleTabChange(next: string) {
|
||||
if (next === 'dns') {
|
||||
void navigate({
|
||||
to: '/domains/$domainId/dns',
|
||||
params: { domainId },
|
||||
search: { host: undefined },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
next === 'overview' ||
|
||||
next === 'availability' ||
|
||||
next === 'subdomains' ||
|
||||
next === 'bindings'
|
||||
) {
|
||||
void navigate({
|
||||
to: '/domains/$domainId',
|
||||
params: { domainId },
|
||||
search: { tab: next },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const kpiCards = domain
|
||||
? [
|
||||
{
|
||||
id: 'status',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'Статус',
|
||||
value: domain.status,
|
||||
hint: <StatusBadge status={domain.status} />,
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
icon: <FolderTreeIcon />,
|
||||
label: 'Группа',
|
||||
value: groupName ?? '—',
|
||||
hint: groupName ? 'В группе' : 'Без группы',
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
icon: <Link2Icon />,
|
||||
label: 'Привязки',
|
||||
value: page.bindings.length,
|
||||
hint: 'Сервисы в зоне',
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Синхронизация',
|
||||
value: formatDate(domain.last_synced_at),
|
||||
hint: 'Последняя синхронизация',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const bindings = page.bindings
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={page.isLoading}
|
||||
isError={page.isError}
|
||||
error={page.error}
|
||||
onRetry={page.refetch}
|
||||
skeleton={<DomainChromeSkeleton />}
|
||||
>
|
||||
{domain ? (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={domain.zone_name}
|
||||
description="Карточка домена"
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => setGroupOpen(true)}>
|
||||
Сменить группу
|
||||
</Button>
|
||||
{bindings.length === 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setChangeIp(bindings[0] ?? null)}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
) : bindings.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
Сменить IP
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{bindings.map((binding) => (
|
||||
<DropdownMenuItem
|
||||
key={binding.id}
|
||||
onClick={() => setChangeIp(binding)}
|
||||
>
|
||||
{binding.hostname}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button type="button" onClick={openCreateSubdomain}>
|
||||
Добавить поддомен
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} />
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'availability', label: 'Доступность' },
|
||||
{
|
||||
id: 'subdomains',
|
||||
label: 'Поддомены',
|
||||
count: page.subdomainRows.length,
|
||||
},
|
||||
{ id: 'bindings', label: 'Привязки', count: bindings.length },
|
||||
{
|
||||
id: 'dns',
|
||||
label: 'DNS',
|
||||
count: dnsQuery.data ? dnsQuery.data.length : undefined,
|
||||
},
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
{activeTab === 'overview' ? <DomainOverviewFacts domainId={id} /> : null}
|
||||
{children({ openCreateSubdomain })}
|
||||
</CountedLineTabs>
|
||||
|
||||
<FormSheet
|
||||
open={groupOpen}
|
||||
onOpenChange={setGroupOpen}
|
||||
title="Сменить группу"
|
||||
description={`Назначить группу для ${domain.zone_name}`}
|
||||
form={groupForm}
|
||||
onSubmit={(values) => {
|
||||
const next = values.group_id === 'none' ? null : Number(values.group_id)
|
||||
updateGroupMutation.mutate(next)
|
||||
}}
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={updateGroupMutation.isPending}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple label="Группа" htmlFor="domain-group-id">
|
||||
<Controller
|
||||
control={groupForm.control}
|
||||
name="group_id"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="domain-group-id" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
|
||||
<SubdomainEditSheet
|
||||
mode="create"
|
||||
subdomain={null}
|
||||
zoneName={domain.zone_name}
|
||||
services={page.services}
|
||||
serviceGroupById={page.serviceGroupById}
|
||||
currentServiceId="none"
|
||||
open={createOpen}
|
||||
isSaving={page.createSubdomainMutation.isPending}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreateSubdomain}
|
||||
/>
|
||||
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.id ?? null}
|
||||
serviceId={changeIp?.service_id ?? null}
|
||||
currentIp={changeIp?.target_ip ?? changeIp?.target_ips?.[0]}
|
||||
/>
|
||||
</DetailPanel>
|
||||
) : null}
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainOverviewFacts({ domainId }: { domainId: number }) {
|
||||
const page = useDomainPage(domainId)
|
||||
const groupsQuery = useQuery(groupsQueryOptions())
|
||||
const domain = page.domain
|
||||
if (!domain) return null
|
||||
|
||||
const groupName = domain.group_id
|
||||
? groupsQuery.data?.find((group) => group.id === domain.group_id)?.name
|
||||
: null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Зона</FrameTitle>
|
||||
<FrameDescription>Параметры зоны Cloudflare</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<SettingRow title="Зона">
|
||||
<span className="text-sm font-medium">{domain.zone_name}</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Группа">
|
||||
{domain.group_id ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/groups/$groupId" params={{ groupId: String(domain.group_id) }} />
|
||||
}
|
||||
>
|
||||
{groupName ?? 'Открыть группу'}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
)}
|
||||
</SettingRow>
|
||||
<SettingRow title="Zone ID">
|
||||
<span className="font-mono text-sm break-all">{domain.cf_zone_id}</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Последняя синхронизация" last>
|
||||
<span className="text-sm tabular-nums">{formatDate(domain.last_synced_at)}</span>
|
||||
</SettingRow>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DOMAIN_SECTION_TABS = [
|
||||
'overview',
|
||||
'availability',
|
||||
'subdomains',
|
||||
'bindings',
|
||||
] as const
|
||||
|
||||
export type DomainSectionTab = (typeof DOMAIN_SECTION_TABS)[number]
|
||||
export type DomainDetailTab = DomainSectionTab | 'dns'
|
||||
@@ -9,15 +9,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
|
||||
interface DomainsBulkToolbarProps {
|
||||
count: number
|
||||
isPending?: boolean
|
||||
groupItems: { label: string; value: string }[]
|
||||
onAssignGroup: (groupId: number | null) => void
|
||||
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
|
||||
onAddTag: (tag: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
@@ -26,23 +22,12 @@ export function DomainsBulkToolbar({
|
||||
isPending = false,
|
||||
groupItems,
|
||||
onAssignGroup,
|
||||
onSetEnvironment,
|
||||
onAddTag,
|
||||
onClear,
|
||||
}: DomainsBulkToolbarProps) {
|
||||
const [groupValue, setGroupValue] = useState('none')
|
||||
const [envValue, setEnvValue] = useState('none')
|
||||
const [tag, setTag] = useState('')
|
||||
|
||||
if (count === 0) return null
|
||||
|
||||
const envItems = [
|
||||
{ label: 'Без env', value: 'none' },
|
||||
{ label: 'prod', value: 'prod' },
|
||||
{ label: 'staging', value: 'staging' },
|
||||
{ label: 'dev', value: 'dev' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="mb-3 w-full">
|
||||
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
|
||||
@@ -74,56 +59,16 @@ export function DomainsBulkToolbar({
|
||||
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
|
||||
}
|
||||
>
|
||||
В группу
|
||||
Назначить группу
|
||||
</Button>
|
||||
<Select
|
||||
items={envItems}
|
||||
value={envValue}
|
||||
onValueChange={(v) => setEnvValue(v ?? 'none')}
|
||||
>
|
||||
<SelectTrigger className="w-32" size="sm">
|
||||
<SelectValue placeholder="Env" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{envItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
onSetEnvironment(
|
||||
envValue === 'none'
|
||||
? null
|
||||
: (envValue as 'prod' | 'staging' | 'dev'),
|
||||
)
|
||||
}
|
||||
onClick={() => onAssignGroup(null)}
|
||||
>
|
||||
Env
|
||||
</Button>
|
||||
<Input
|
||||
className="h-8 w-32"
|
||||
placeholder="тег"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending || !tag.trim()}
|
||||
onClick={() => {
|
||||
onAddTag(tag.trim())
|
||||
setTag('')
|
||||
}}
|
||||
>
|
||||
+ тег
|
||||
Снять группу
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
interface IncidentAlertProps {
|
||||
title: string
|
||||
description?: string
|
||||
onShow: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Баннер самого тяжёлого инцидента.
|
||||
* Preview: https://reui.io/docs/components/base/alert
|
||||
*/
|
||||
export function IncidentAlert({ title, description, onShow }: IncidentAlertProps) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon aria-hidden />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{description ? <AlertDescription>{description}</AlertDescription> : null}
|
||||
<AlertAction>
|
||||
<Button type="button" variant="outline" size="xs" onClick={onShow}>
|
||||
Показать
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { TriangleAlertIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
@@ -57,11 +59,23 @@ export function VpsTrackerIntegrationCard({
|
||||
|
||||
const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary'
|
||||
|
||||
const hasUnsaved = form.formState.isDirty
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
|
||||
>
|
||||
{hasUnsaved ? (
|
||||
<Alert variant="warning" role="status">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Есть несохранённые изменения</AlertTitle>
|
||||
<AlertDescription>
|
||||
URL, токен или синхронизация отличаются от последних сохранённых
|
||||
значений.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={syncBadgeVariant}>
|
||||
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useEffect, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { CommandPalette } from '@/components/layout/command-palette'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SearchMenu } from '@/components/layout/search-menu'
|
||||
import { SystemAttentionProvider } from '@/components/layout/system-attention-provider'
|
||||
import { bindLiveRefresh } from '@/lib/live-refresh'
|
||||
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
|
||||
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => bindLiveRefresh(queryClient), [queryClient])
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<SearchMenu hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
<SystemAttentionProvider>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</SystemAttentionProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
PaletteIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@cfdm/ui/components/command'
|
||||
import {
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
export const OPEN_COMMAND_PALETTE_EVENT = 'cfdm-open-command-palette'
|
||||
|
||||
const SECTION_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель',
|
||||
keywords: 'dashboard панель управление обзор',
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
to: '/domains',
|
||||
label: 'Домены',
|
||||
keywords: 'domains зоны cloudflare',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
keywords: 'services сервисы fqdn',
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
to: '/groups',
|
||||
label: 'Группы',
|
||||
keywords: 'groups группы доменов',
|
||||
icon: FolderTreeIcon,
|
||||
},
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
keywords: 'certificates ssl tls',
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/appearance',
|
||||
label: 'Настройки',
|
||||
keywords: 'settings настройки',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
type PaletteTarget = (typeof SECTION_ITEMS)[number]['to']
|
||||
|
||||
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const domainsQuery = useQuery({ ...domainsListQueryOptions(), enabled: open })
|
||||
const groupsQuery = useQuery({ ...groupsQueryOptions(), enabled: open })
|
||||
const servicesQuery = useQuery({ ...serviceGroupsQueryOptions(), enabled: open })
|
||||
|
||||
const services = useMemo(() => {
|
||||
const data = servicesQuery.data
|
||||
if (!data) return []
|
||||
return [...data.groups.flatMap((group) => group.services), ...data.ungrouped]
|
||||
}, [servicesQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.repeat) return
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
|
||||
event.preventDefault()
|
||||
setOpen((current) => !current)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function go(to: PaletteTarget) {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Разделы и действия приложения"
|
||||
className="sm:max-w-md"
|
||||
>
|
||||
<Command key={open ? 'open' : 'closed'}>
|
||||
<CommandInput autoFocus placeholder="Раздел, домен, сервис или действие…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||
<CommandGroup heading="Действия">
|
||||
<CommandItem value="Синхронизировать домены" onSelect={() => go('/domains')}>
|
||||
<GlobeIcon />
|
||||
<span>Синхронизировать домены</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Создать сервис" onSelect={() => go('/services')}>
|
||||
<ServerIcon />
|
||||
<span>Создать сервис</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Создать группу" onSelect={() => go('/groups')}>
|
||||
<FolderTreeIcon />
|
||||
<span>Создать группу</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Проверить сертификаты" onSelect={() => go('/certificates')}>
|
||||
<ShieldCheckIcon />
|
||||
<span>Проверить сертификаты</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
value="Переключить тему"
|
||||
onSelect={() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<PaletteIcon />
|
||||
<span>Переключить тему</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Разделы">
|
||||
{SECTION_ITEMS.map((item) => (
|
||||
<CommandItem
|
||||
key={item.to}
|
||||
value={`${item.label} ${item.keywords}`}
|
||||
onSelect={() => go(item.to)}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Домены">
|
||||
{(domainsQuery.data ?? []).slice(0, 8).map((domain) => (
|
||||
<CommandItem
|
||||
key={domain.id}
|
||||
value={`домен ${domain.zone_name}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/domains/$domainId',
|
||||
params: { domainId: String(domain.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<GlobeIcon />
|
||||
<span>{domain.zone_name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Сервисы">
|
||||
{services.slice(0, 8).map((service) => (
|
||||
<CommandItem
|
||||
key={service.id}
|
||||
value={`сервис ${service.name}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/services/$serviceId',
|
||||
params: { serviceId: String(service.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ServerIcon />
|
||||
<span>{service.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Группы">
|
||||
{(groupsQuery.data ?? []).slice(0, 8).map((group) => (
|
||||
<CommandItem
|
||||
key={group.id}
|
||||
value={`группа ${group.name} ${group.slug}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/groups/$groupId',
|
||||
params: { groupId: String(group.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FolderTreeIcon />
|
||||
<span>{group.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { isLiveRefresh, setLiveRefresh } from '@/lib/live-refresh'
|
||||
|
||||
function useLatestDataUpdatedAt() {
|
||||
const queryClient = useQueryClient()
|
||||
const [updatedAt, setUpdatedAt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const cache = queryClient.getQueryCache()
|
||||
let queued = false
|
||||
let active = true
|
||||
const read = () => {
|
||||
let latest = 0
|
||||
for (const query of cache.getAll()) {
|
||||
if (query.getObserversCount() === 0) continue
|
||||
if (query.state.dataUpdatedAt > latest) latest = query.state.dataUpdatedAt
|
||||
}
|
||||
setUpdatedAt((prev) => (prev === latest ? prev : latest))
|
||||
}
|
||||
// Cache notifies synchronously while another route renders (observerAdded).
|
||||
const schedule = () => {
|
||||
if (queued) return
|
||||
queued = true
|
||||
queueMicrotask(() => {
|
||||
queued = false
|
||||
if (active) read()
|
||||
})
|
||||
}
|
||||
read()
|
||||
const unsubscribe = cache.subscribe(schedule)
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
return updatedAt
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number) {
|
||||
if (updatedAt === 0) return '—'
|
||||
return formatRelative(new Date(updatedAt).toISOString())
|
||||
}
|
||||
|
||||
/** Компактный статус свежести слева от AppsMenu. */
|
||||
export function DataFreshness() {
|
||||
const queryClient = useQueryClient()
|
||||
const updatedAt = useLatestDataUpdatedAt()
|
||||
const [live, setLive] = useState(() => isLiveRefresh())
|
||||
const [label, setLabel] = useState('—')
|
||||
|
||||
useEffect(() => {
|
||||
const read = () => setLabel(formatUpdatedAt(updatedAt))
|
||||
read()
|
||||
if (updatedAt === 0) return
|
||||
const timer = window.setInterval(read, 15_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [updatedAt])
|
||||
|
||||
function handleCheckedChange(checked: boolean) {
|
||||
const next = Boolean(checked)
|
||||
setLiveRefresh(queryClient, next)
|
||||
setLive(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex">
|
||||
<span className="tabular-nums">
|
||||
Обновлено{' '}
|
||||
<time dateTime={updatedAt ? new Date(updatedAt).toISOString() : undefined}>
|
||||
{label}
|
||||
</time>
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{live ? 'Live' : 'Пауза'}</span>
|
||||
</p>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={live}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
aria-label="Автообновление"
|
||||
/>
|
||||
<span className="text-muted-foreground hidden text-xs sm:inline">авто</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
HeartPulseIcon,
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@cfdm/ui/components/dialog'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель управления',
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
to: '/domains',
|
||||
label: 'Домены',
|
||||
keywords: ['domains', 'зоны', 'cloudflare'],
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
to: '/groups',
|
||||
label: 'Группы доменов',
|
||||
keywords: ['groups', 'группы', 'канбан'],
|
||||
icon: FolderTreeIcon,
|
||||
},
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
keywords: ['services', 'сервисы', 'fqdn'],
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
keywords: ['certificates', 'ssl', 'tls'],
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/health',
|
||||
label: 'Health-check',
|
||||
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
|
||||
icon: HeartPulseIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/integrations',
|
||||
label: 'Настройки',
|
||||
keywords: ['settings', 'интеграции', 'app switcher'],
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Command-K search — hotkey dialog (no header chrome trigger). Etalon: EvoBGP CommandPalette hotkeyOnly. */
|
||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return NAV_ITEMS
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.keywords.some((k) => k.includes(q)),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
function goTo(to: string) {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotkeyOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-4 opacity-60 select-none"
|
||||
/>
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Перейти к разделу…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) {
|
||||
e.preventDefault()
|
||||
goTo(filtered[0].to)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<Item
|
||||
key={item.to}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="cursor-pointer border-0"
|
||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||
import { MenuIcon } from 'lucide-react'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -9,9 +10,12 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { DataFreshness } from '@/components/layout/data-freshness'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
import { OPEN_COMMAND_PALETTE_EVENT } from '@/components/layout/command-palette'
|
||||
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
@@ -32,7 +36,7 @@ function useDynamicBreadcrumbLabels() {
|
||||
}, [matches])
|
||||
}
|
||||
|
||||
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||
/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
@@ -44,6 +48,15 @@ export function SiteHeader() {
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Меню и поиск"
|
||||
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
||||
>
|
||||
<MenuIcon aria-hidden />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
@@ -71,6 +84,7 @@ export function SiteHeader() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<DataFreshness />
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
SystemAttentionContext,
|
||||
useSystemAttentionData,
|
||||
} from '@/hooks/use-system-attention'
|
||||
|
||||
export function SystemAttentionProvider({ children }: { children: ReactNode }) {
|
||||
const attention = useSystemAttentionData()
|
||||
return (
|
||||
<SystemAttentionContext.Provider value={attention}>
|
||||
{children}
|
||||
</SystemAttentionContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Activity,
|
||||
HeartPulse,
|
||||
@@ -8,18 +8,14 @@ import {
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { useSystemAttention } from '@/hooks/use-system-attention'
|
||||
import type { SystemIncident } from '@/lib/system-attention'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
|
||||
import {
|
||||
certSummaryQueryOptions,
|
||||
domainsListQueryOptions,
|
||||
notificationLogQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
const VISIBLE_INCIDENTS = 8
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
@@ -32,12 +28,6 @@ type MonitorMetric = {
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
type ReadyResponse = {
|
||||
status: string
|
||||
database?: boolean
|
||||
cloudflare?: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
@@ -81,110 +71,95 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
)
|
||||
}
|
||||
|
||||
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
||||
if (!summary) return 0
|
||||
return summary
|
||||
.filter(([status]) => statuses.includes(status))
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
function IncidentLink({ incident }: { incident: SystemIncident }) {
|
||||
const className =
|
||||
'hover:bg-muted flex items-center gap-2 rounded-md px-1.5 py-1 text-xs'
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
incident.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{incident.title}</span>
|
||||
<span className="text-muted-foreground shrink-0">{incident.detail}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (incident.href.to === '/certificates') {
|
||||
return (
|
||||
<Link to="/certificates" className={className}>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: incident.href.serviceId }}
|
||||
className={className}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell pattern, CFDM API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const readyQ = useQuery({
|
||||
queryKey: ['ready'],
|
||||
queryFn: async (): Promise<ReadyResponse> => {
|
||||
const res = await fetch('/ready')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<ReadyResponse>
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 })
|
||||
const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 })
|
||||
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 })
|
||||
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 })
|
||||
|
||||
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
|
||||
const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError
|
||||
|
||||
let healthDown = 0
|
||||
let healthDegraded = 0
|
||||
for (const group of servicesQ.data?.groups ?? []) {
|
||||
for (const service of group.services) {
|
||||
if (service.health_status === 'down') healthDown += 1
|
||||
if (service.health_status === 'degraded') healthDegraded += 1
|
||||
}
|
||||
}
|
||||
for (const service of servicesQ.data?.ungrouped ?? []) {
|
||||
if (service.health_status === 'down') healthDown += 1
|
||||
if (service.health_status === 'degraded') healthDegraded += 1
|
||||
}
|
||||
const healthAlert = healthDown > 0
|
||||
const healthWarn = healthDegraded > 0
|
||||
|
||||
const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error'])
|
||||
const failedJobs = (notifyQ.data ?? []).filter((n) =>
|
||||
/fail|error/i.test(`${n.kind} ${n.title} ${n.message}`),
|
||||
).length
|
||||
const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length
|
||||
const attentionCount = healthDown + healthDegraded
|
||||
const attention = useSystemAttention()
|
||||
const visibleIncidents = attention.incidents.slice(0, VISIBLE_INCIDENTS)
|
||||
const hiddenIncidents = attention.incidents.length - visibleIncidents.length
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ready',
|
||||
label: 'Ready',
|
||||
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
|
||||
value: attention.readyOk ? 'OK' : attention.readyDegraded ? '!' : '—',
|
||||
unit: '',
|
||||
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
|
||||
percent: attention.readyOk ? 100 : attention.readyDegraded ? 40 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
tone: readyOk ? 'success' : 'destructive',
|
||||
alert: !readyOk,
|
||||
tone: attention.readyOk ? 'success' : 'destructive',
|
||||
alert: !attention.readyOk,
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
label: 'Health-check',
|
||||
value: String(healthDown + healthDegraded),
|
||||
value: String(attention.servicesDown + attention.servicesDegraded),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, (healthDown + healthDegraded) * 20),
|
||||
percent: Math.min(100, (attention.servicesDown + attention.servicesDegraded) * 20),
|
||||
icon: <Server aria-hidden />,
|
||||
tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success',
|
||||
alert: healthAlert || healthWarn,
|
||||
tone: attention.servicesDown > 0 ? 'destructive' : attention.servicesDegraded > 0 ? 'warning' : 'success',
|
||||
alert: attention.servicesDown > 0 || attention.servicesDegraded > 0,
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
label: 'Сертификаты',
|
||||
value: String(certWarnings),
|
||||
value: String(attention.certWarnings),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, certWarnings * 20),
|
||||
percent: Math.min(100, attention.certWarnings * 20),
|
||||
icon: <ShieldCheck aria-hidden />,
|
||||
tone: certWarnings > 0 ? 'warning' : 'success',
|
||||
alert: certWarnings > 0,
|
||||
tone: attention.certWarnings > 0 ? 'warning' : 'success',
|
||||
alert: attention.certWarnings > 0,
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Ошибки',
|
||||
value: String(failedJobs),
|
||||
value: String(attention.failedJobs),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedJobs * 15),
|
||||
percent: Math.min(100, attention.failedJobs * 15),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: failedJobs > 0,
|
||||
tone: attention.failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: attention.failedJobs > 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
certWarnings,
|
||||
failedJobs,
|
||||
healthAlert,
|
||||
healthDegraded,
|
||||
healthDown,
|
||||
healthWarn,
|
||||
readyDegraded,
|
||||
readyOk,
|
||||
],
|
||||
[attention],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert)
|
||||
const spiking = metrics.some((metric) => metric.alert)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -192,7 +167,11 @@ export function SystemMonitorPopover() {
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
aria-label={
|
||||
spiking
|
||||
? `Состояние системы: внимание, ошибок ${attention.servicesDown}, предупреждений ${attention.servicesDegraded + attention.certWarnings}`
|
||||
: 'Состояние системы: норма'
|
||||
}
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
@@ -212,14 +191,15 @@ export function SystemMonitorPopover() {
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden items-center gap-1 text-[11px] tabular-nums md:inline-flex">
|
||||
<i className="bg-destructive inline-block size-1.5 rounded-full" aria-hidden />
|
||||
{attention.servicesDown}
|
||||
<i className="bg-warning inline-block size-1.5 rounded-full" aria-hidden />
|
||||
{attention.servicesDegraded + attention.certWarnings}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
@@ -242,12 +222,29 @@ export function SystemMonitorPopover() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border flex flex-col gap-1 border-t px-2 py-2">
|
||||
<span className="text-muted-foreground px-1.5 text-xs font-medium">Инциденты</span>
|
||||
{visibleIncidents.length === 0 ? (
|
||||
<p className="text-muted-foreground px-1.5 py-1 text-xs">Нет инцидентов</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{visibleIncidents.map((incident) => (
|
||||
<IncidentLink key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hiddenIncidents > 0 ? (
|
||||
<p className="text-muted-foreground px-1.5 text-xs">Ещё {hiddenIncidents}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Без группы:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{ungroupedCount}</span>
|
||||
<span className="text-foreground font-medium tabular-nums">{attention.ungroupedCount}</span>
|
||||
{' · '}
|
||||
Внимание:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{attentionCount}</span>
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{attention.servicesDown + attention.servicesDegraded}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -41,6 +41,14 @@ import {
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
import {
|
||||
readContrast,
|
||||
readDensity,
|
||||
writeContrast,
|
||||
writeDensity,
|
||||
type ContrastMode,
|
||||
type DensityMode,
|
||||
} from '@/lib/appearance-mode'
|
||||
|
||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
@@ -107,6 +115,98 @@ function ThemeSegmentedToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedChoice<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly { value: T; label: string }[]
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isActive = value === option.value
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
size="xs"
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceToggles() {
|
||||
const [contrast, setContrast] = useState<ContrastMode>('standard')
|
||||
const [density, setDensity] = useState<DensityMode>('normal')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setContrast(readContrast())
|
||||
setDensity(readDensity())
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<span className="text-sm">Контраст</span>
|
||||
<div className="ml-auto">
|
||||
<SegmentedChoice
|
||||
label="Контраст"
|
||||
value={contrast}
|
||||
options={[
|
||||
{ value: 'standard', label: 'Обычный' },
|
||||
{ value: 'high', label: 'Высокий' },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setContrast(next)
|
||||
writeContrast(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<span className="text-sm">Плотность</span>
|
||||
<div className="ml-auto">
|
||||
<SegmentedChoice
|
||||
label="Плотность"
|
||||
value={density}
|
||||
options={[
|
||||
{ value: 'normal', label: 'Обычная' },
|
||||
{ value: 'compact', label: 'Компакт' },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setDensity(next)
|
||||
writeDensity(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string, email: string): string {
|
||||
const base = name.trim() || email.trim()
|
||||
if (!base) return '?'
|
||||
@@ -202,6 +302,7 @@ export function NavUser() {
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<AppearanceToggles />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -16,7 +16,9 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { CircleAlertIcon, type LucideIcon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
@@ -9,12 +14,14 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Sibling Frame columns for dashboard attention queue.
|
||||
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||
* Each column is its own Frame; children must not wrap another Frame.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/dashboard-1
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
export interface AttentionQueueColumn {
|
||||
id: string
|
||||
@@ -26,6 +33,9 @@ export interface AttentionQueueColumn {
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
emptyAction?: ReactNode
|
||||
isError?: boolean
|
||||
errorMessage?: string
|
||||
onRetry?: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
@@ -40,13 +50,14 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
|
||||
'flex min-w-0 flex-col items-stretch gap-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const Icon = column.icon
|
||||
const isEmpty = column.count === 0
|
||||
const isError = column.isError === true
|
||||
const isEmpty = !isError && column.count === 0
|
||||
return (
|
||||
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader>
|
||||
@@ -71,8 +82,30 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
) : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="min-w-0">
|
||||
{isEmpty ? (
|
||||
<FramePanel className={cn('min-w-0', !isEmpty && !isError && 'p-0')}>
|
||||
{isError ? (
|
||||
<div className="flex min-h-28 items-center py-4">
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{column.errorMessage ?? 'Не удалось загрузить данные'}
|
||||
</span>
|
||||
{column.onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={column.onRetry}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex min-h-28 items-center justify-center py-4">
|
||||
<EmptyState
|
||||
icon={Icon}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useEffect, useRef } from 'react'
|
||||
import { ActivityIcon } from 'lucide-react'
|
||||
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -229,3 +230,31 @@ export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок «Здоровье нод» в форме area-chart (chart-17): заголовок и легенда.
|
||||
* Почасового ряда Healthy/Unhealthy API не отдаёт — пустое состояние, без выдуманных точек.
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function NodeHealthChart() {
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Здоровье нод</FrameTitle>
|
||||
<FrameDescription>
|
||||
Healthy / Unhealthy по проверкам health-check · последние 24 часа
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex min-h-52 flex-col">
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет ряда за 24 часа"
|
||||
description="Сводка нод есть. Почасовой ряд Healthy и Unhealthy API не отдаёт, поэтому точки не подставляются."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
import type { Certificate, DomainListItem, ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
/**
|
||||
* Compact queue tables for the dashboard. Parent Frame owns the shell —
|
||||
* this grid does not render another Frame.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Docs: https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
|
||||
function formatWhen(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
return formatRelative(sqliteUtcToIso(value) ?? value)
|
||||
}
|
||||
|
||||
function nodeLabel(service: ServiceView) {
|
||||
const health = service.ip_health
|
||||
const total = Math.max(service.ips.length, health.length)
|
||||
if (total === 0) return '—'
|
||||
if (health.length === 0) return String(total)
|
||||
const healthy = health.filter((row) => row.status === 'up').length
|
||||
return `${healthy}/${total}`
|
||||
}
|
||||
|
||||
function latestCheck(service: ServiceView) {
|
||||
let checkedAt: string | null = null
|
||||
for (const row of service.ip_health) {
|
||||
if (!row.last_checked_at) continue
|
||||
if (!checkedAt || row.last_checked_at > checkedAt) checkedAt = row.last_checked_at
|
||||
}
|
||||
return checkedAt
|
||||
}
|
||||
|
||||
function QueueGrid<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
}: {
|
||||
data: T[]
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
getRowId: (row: T) => string
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
headerBorder: true,
|
||||
rowBorder: true,
|
||||
width: 'auto',
|
||||
}}
|
||||
>
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface HealthProblemGridProps {
|
||||
services: ServiceView[]
|
||||
}
|
||||
|
||||
export function HealthProblemGrid({ services }: HealthProblemGridProps) {
|
||||
const columns = useMemo<ColumnDef<ServiceView, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
size: 140,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервис" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ serviceId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'health_status',
|
||||
size: 96,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge status={row.original.health_status} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
accessorKey: 'health_latency_ms',
|
||||
size: 88,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Задержка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{row.original.health_latency_ms != null
|
||||
? `${row.original.health_latency_ms} мс`
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
accessorFn: (row) => Math.max(row.ips.length, row.ip_health.length),
|
||||
size: 72,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Ноды" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{nodeLabel(row.original)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'checked',
|
||||
accessorFn: (row) => latestCheck(row) ?? '',
|
||||
size: 104,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Когда" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(latestCheck(row.original))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid
|
||||
data={services}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExpiringCertGridProps {
|
||||
certs: Certificate[]
|
||||
}
|
||||
|
||||
export function ExpiringCertGrid({ certs }: ExpiringCertGridProps) {
|
||||
const columns = useMemo<ColumnDef<Certificate, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Хост" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
{row.original.hostname}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
accessorFn: (row) => row.service_name ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервис" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate">
|
||||
{row.original.service_name?.trim() || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expires',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid data={certs} columns={columns} getRowId={(row) => String(row.id)} />
|
||||
)
|
||||
}
|
||||
|
||||
interface UngroupedDomainGridProps {
|
||||
domains: DomainListItem[]
|
||||
}
|
||||
|
||||
export function UngroupedDomainGrid({ domains }: UngroupedDomainGridProps) {
|
||||
const columns = useMemo<ColumnDef<DomainListItem, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Домен" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.service_count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'synced',
|
||||
accessorKey: 'last_synced_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Синхронизация" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(row.original.last_synced_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid
|
||||
data={domains}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent
|
||||
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,
|
||||
@@ -17,6 +17,11 @@ export {
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
|
||||
export {
|
||||
ExpiringCertGrid,
|
||||
HealthProblemGrid,
|
||||
UngroupedDomainGrid,
|
||||
} from './dashboard-queue-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
@@ -40,14 +39,18 @@ export type OpsKpiCard = KpiStatCardData
|
||||
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||
export type KpiStatCard = KpiStatCardData
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground'
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: 'text-foreground',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
const HINT_VARIANT: Record<KpiStatVariant, 'outline' | 'warning-light' | 'destructive-light'> = {
|
||||
default: 'outline',
|
||||
warning: 'warning-light',
|
||||
destructive: 'destructive-light',
|
||||
}
|
||||
|
||||
/** stats-12 tile: https://reui.io/preview/base/stats-12 */
|
||||
const STAT_ICON_CLASS =
|
||||
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5'
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
@@ -67,7 +70,11 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||
<Badge
|
||||
variant={HINT_VARIANT[item.variant ?? 'default']}
|
||||
size="sm"
|
||||
className="max-w-full truncate"
|
||||
>
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
@@ -78,43 +85,30 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||
<div className="relative z-10 flex h-full min-w-0 flex-col items-start gap-6">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
<Item
|
||||
className={cn(STAT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="text-foreground min-w-0 text-2xl leading-none font-bold break-all tabular-nums">
|
||||
{item.value}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||
) : null}
|
||||
<div className="text-muted-foreground min-w-0 text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,24 +190,28 @@ export function KpiStatCard({
|
||||
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||
}
|
||||
|
||||
function statGridClass(count: number) {
|
||||
if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
|
||||
if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
|
||||
return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<FramePanel key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||
</div>
|
||||
<div className={cn('@container grid w-full', statGridClass(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<Frame key={index}>
|
||||
<FramePanel className="flex flex-col items-start gap-6">
|
||||
<Skeleton className="size-10.5 rounded-lg" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-7 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-28 rounded-full" />
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,44 +230,10 @@ interface KpiStatGridProps {
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||
* KPI grid from stats-12: one Frame per metric, icon tile, value, label, badge.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/blocks/application/stats/stats-12
|
||||
*/
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
@@ -301,26 +265,18 @@ export function KpiStatGrid({
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid', statGridClass(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
<KpiStatCardTile
|
||||
key={kpiStatItemKey(item, index)}
|
||||
item={item}
|
||||
embedded={embedded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ interface OpsDashboardProps {
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
queueTitle?: string
|
||||
queueDescription?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
@@ -48,7 +47,6 @@ export function OpsDashboard({
|
||||
charts,
|
||||
queue,
|
||||
queueTitle = 'Требуют внимания',
|
||||
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
|
||||
isLoading = false,
|
||||
}: OpsDashboardProps) {
|
||||
if (isLoading) {
|
||||
@@ -63,22 +61,11 @@ export function OpsDashboard({
|
||||
|
||||
{afterKpi}
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">
|
||||
{queueDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||
{queue}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
@@ -20,6 +18,7 @@ export interface QuickActionItem {
|
||||
search?: Record<string, unknown>
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
badgeVariant?: 'info-light' | 'warning-light'
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
@@ -29,39 +28,10 @@ interface QuickActionGridProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
Перейти
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Quick links from card-12, with the artboard title, description and «Перейти».
|
||||
* Preview: https://reui.io/preview/base/card-12
|
||||
* Docs: https://reui.io/blocks/application/card/card-12
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
@@ -72,28 +42,37 @@ export function QuickActionGrid({
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||
{(title || description) && (
|
||||
<Frame className={cn('w-full', className)}>
|
||||
{title || description ? (
|
||||
<FrameHeader>
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
)}
|
||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||
) : null}
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{actions.map((action) => (
|
||||
<FramePanel
|
||||
<Link
|
||||
key={action.id}
|
||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
<FramePanel className="flex h-full flex-col gap-3 p-3.5">
|
||||
<span className={cn('[&_svg]:size-5', action.iconClassName)} aria-hidden="true">
|
||||
{action.icon}
|
||||
</span>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
|
||||
{action.title}
|
||||
</h3>
|
||||
<Badge variant={action.badgeVariant ?? 'info-light'} size="sm">
|
||||
Перейти
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{action.description}</p>
|
||||
</FramePanel>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
FilterIcon,
|
||||
FunnelXIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
@@ -67,7 +72,27 @@ export interface ResourcePageProps<T extends object> {
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
primaryAction?: ReactNode
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
emptyState?: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
icon?: LucideIcon
|
||||
}
|
||||
/** Пустой результат фильтров или вкладки. Preview: https://reui.io/preview/base/empty-state-12 */
|
||||
filteredEmptyState?: {
|
||||
title: string
|
||||
description?: string
|
||||
resetLabel?: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
onFilteredEmptyReset?: () => void
|
||||
/** Ошибка загрузки через EmptyState вместо общего Alert. */
|
||||
errorState?: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
icon?: LucideIcon
|
||||
}
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
selectionToolbar?: (ctx: {
|
||||
@@ -119,6 +144,9 @@ export function ResourcePage<T extends object>({
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
filteredEmptyState,
|
||||
onFilteredEmptyReset,
|
||||
errorState,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
selectionToolbar,
|
||||
@@ -224,6 +252,25 @@ export function ResourcePage<T extends object>({
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (errorState) {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
|
||||
<EmptyState
|
||||
icon={errorState.icon ?? CircleAlertIcon}
|
||||
title={errorState.title}
|
||||
description={
|
||||
errorState.description ??
|
||||
error?.message ??
|
||||
'Не удалось загрузить данные'
|
||||
}
|
||||
action={errorState.action}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
@@ -262,6 +309,7 @@ export function ResourcePage<T extends object>({
|
||||
) : null}
|
||||
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
|
||||
<EmptyState
|
||||
icon={emptyState.icon}
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={emptyState.action}
|
||||
@@ -271,7 +319,30 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
const emptyMessage = filteredEmptyState ? (
|
||||
<EmptyState
|
||||
className="text-foreground"
|
||||
icon={filteredEmptyState.icon}
|
||||
title={filteredEmptyState.title}
|
||||
description={filteredEmptyState.description}
|
||||
action={
|
||||
filteredEmptyState.resetLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleClear()
|
||||
onFilteredEmptyReset?.()
|
||||
}}
|
||||
>
|
||||
{filteredEmptyState.resetLabel}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
'Нет записей по выбранным фильтрам.'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -16,10 +16,13 @@ import { parseFqdn } from '@/lib/parse-fqdn'
|
||||
import {
|
||||
addAddressNode,
|
||||
addCommonFqdn,
|
||||
addExtraFqdn,
|
||||
addressHasFqdn,
|
||||
removeAddressNode,
|
||||
removeCommonFqdn,
|
||||
removeExtraFqdn,
|
||||
updateCommonFqdn,
|
||||
updateExtraFqdn,
|
||||
type AddressBlockState,
|
||||
} from '@/lib/service-address'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
@@ -67,7 +70,7 @@ function ZoneAddon({
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменом.
|
||||
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменами.
|
||||
* Preview: https://reui.io/preview/base/settings-3
|
||||
* Preview: https://reui.io/preview/base/list-9
|
||||
* Preview: https://reui.io/preview/base/form-7
|
||||
@@ -88,6 +91,8 @@ export function ServiceAddressBlock({
|
||||
const [ipInvalid, setIpInvalid] = useState(false)
|
||||
const [pendingFqdn, setPendingFqdn] = useState('')
|
||||
const [fqdnInvalid, setFqdnInvalid] = useState(false)
|
||||
const [pendingExtraByIp, setPendingExtraByIp] = useState<Record<string, string>>({})
|
||||
const [extraInvalidByIp, setExtraInvalidByIp] = useState<Record<string, boolean>>({})
|
||||
|
||||
const pool = value.nodes.map((node) => node.ip)
|
||||
const pendingIpTrimmed = pendingIp.trim()
|
||||
@@ -141,13 +146,26 @@ export function ServiceAddressBlock({
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeFqdn(ip: string, extraFqdn: string) {
|
||||
onChange({
|
||||
...value,
|
||||
nodes: value.nodes.map((node) =>
|
||||
node.ip === ip ? { ...node, extraFqdn } : node,
|
||||
),
|
||||
})
|
||||
function tryAddExtra(ip: string, raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
|
||||
return
|
||||
}
|
||||
if (addressHasFqdn(value, trimmed)) {
|
||||
setExtraInvalidByIp((current) => ({ ...current, [ip]: true }))
|
||||
return
|
||||
}
|
||||
onChange(addExtraFqdn(value, ip, trimmed))
|
||||
setPendingExtraByIp((current) => ({ ...current, [ip]: '' }))
|
||||
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
|
||||
}
|
||||
|
||||
function handleExtraKeyDown(ip: string, event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
tryAddExtra(ip, pendingExtraByIp[ip] ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -156,7 +174,7 @@ export function ServiceAddressBlock({
|
||||
<FrameHeader className="px-0 pt-0">
|
||||
<FrameTitle>Адреса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Общие FQDN — на весь пул · у IP свой доп. домен
|
||||
Общие FQDN — на весь пул · у IP свои доп. домены
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<Field>
|
||||
@@ -224,7 +242,7 @@ export function ServiceAddressBlock({
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Добавьте IP пула"
|
||||
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
|
||||
description="IPv4 сервиса. Для каждого адреса можно указать несколько доп. FQDN, в том числе wildcard."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
@@ -265,27 +283,98 @@ export function ServiceAddressBlock({
|
||||
</div>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel
|
||||
htmlFor={`service-ip-extra-${node.ip}`}
|
||||
htmlFor={`service-ip-extra-add-${node.ip}`}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Доп. FQDN
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`service-ip-extra-${node.ip}`}
|
||||
className="font-mono"
|
||||
value={node.extraFqdn}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `необязательно · spb.${zoneHints[0]}`
|
||||
: 'необязательно · spb.example.com'
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleNodeFqdn(node.ip, event.target.value)
|
||||
}
|
||||
/>
|
||||
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
|
||||
</InputGroup>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{node.extraFqdns.map((fqdn, index) => (
|
||||
<InputGroup key={`extra-fqdn-${node.ip}-${index}`}>
|
||||
<InputGroupInput
|
||||
id={`service-ip-extra-${node.ip}-${index}`}
|
||||
className="font-mono"
|
||||
value={fqdn}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `*.mdns.${zoneHints[0]}`
|
||||
: '*.mdns.example.com'
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
updateExtraFqdn(
|
||||
value,
|
||||
node.ip,
|
||||
index,
|
||||
event.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={fqdn}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Удалить ${fqdn || 'FQDN'}`}
|
||||
onClick={() =>
|
||||
onChange(removeExtraFqdn(value, node.ip, index))
|
||||
}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
))}
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`service-ip-extra-add-${node.ip}`}
|
||||
className="font-mono"
|
||||
value={pendingExtraByIp[node.ip] ?? ''}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `необязательно · *.mdns.${zoneHints[0]}`
|
||||
: 'необязательно · *.mdns.example.com'
|
||||
}
|
||||
aria-invalid={
|
||||
extraInvalidByIp[node.ip] &&
|
||||
(pendingExtraByIp[node.ip] ?? '').trim().length > 0
|
||||
? true
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => {
|
||||
setPendingExtraByIp((current) => ({
|
||||
...current,
|
||||
[node.ip]: event.target.value,
|
||||
}))
|
||||
setExtraInvalidByIp((current) => ({
|
||||
...current,
|
||||
[node.ip]: false,
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => handleExtraKeyDown(node.ip, event)}
|
||||
onBlur={() =>
|
||||
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
|
||||
}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={pendingExtraByIp[node.ip] ?? ''}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
|
||||
}
|
||||
>
|
||||
Добавить
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</Field>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
@@ -75,18 +75,25 @@ function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
|
||||
*/
|
||||
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 = PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin
|
||||
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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -219,8 +219,8 @@ export function ServiceEditSheet({
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп.
|
||||
FQDN.
|
||||
Общие FQDN на весь пул IP. У каждого адреса можно указать несколько доп.
|
||||
FQDN, в том числе wildcard.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -228,6 +228,8 @@ interface ServiceDetailGridProps {
|
||||
onAddNode: () => void
|
||||
onDeleteNode: (nodeId: number) => void
|
||||
isLoading?: boolean
|
||||
/** Фиксирует одну секцию без внутренних вкладок IP / FQDN / Ноды / SSL. */
|
||||
section?: 'ip' | 'fqdn' | 'nodes' | 'ssl'
|
||||
}
|
||||
|
||||
export function ServiceDetailGrid({
|
||||
@@ -241,8 +243,10 @@ export function ServiceDetailGrid({
|
||||
onAddNode,
|
||||
onDeleteNode,
|
||||
isLoading = false,
|
||||
section,
|
||||
}: ServiceDetailGridProps) {
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
|
||||
const [tabState, setTab] = useState<(typeof TABS)[number]['id']>(section ?? 'ip')
|
||||
const tab = section ?? tabState
|
||||
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
|
||||
createFilter('ip', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
@@ -671,17 +675,34 @@ export function ServiceDetailGrid({
|
||||
[patchCertMonitoring],
|
||||
)
|
||||
|
||||
const sharedTabs = {
|
||||
tabs,
|
||||
activeTab: tab,
|
||||
onTabChange: (id: string) => setTab(id as typeof tab),
|
||||
}
|
||||
const heading =
|
||||
section === 'nodes'
|
||||
? { title: 'Ноды', description: 'Адреса пула и состояние проверок' }
|
||||
: section === 'fqdn'
|
||||
? { title: 'Поддомены', description: 'FQDN, привязанные к сервису' }
|
||||
: section === 'ip'
|
||||
? {
|
||||
title: 'Маршрутизация',
|
||||
description: 'Веса, приоритеты и включённые адреса',
|
||||
}
|
||||
: {
|
||||
title: 'Активы сервиса',
|
||||
description: 'IP, FQDN и ноды этого сервиса',
|
||||
}
|
||||
|
||||
const sharedTabs = section
|
||||
? {}
|
||||
: {
|
||||
tabs,
|
||||
activeTab: tab,
|
||||
onTabChange: (id: string) => setTab(id as typeof tab),
|
||||
}
|
||||
|
||||
if (tab === 'fqdn') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={fqdnFilterFields}
|
||||
filters={fqdnFilters}
|
||||
@@ -711,8 +732,8 @@ export function ServiceDetailGrid({
|
||||
if (tab === 'nodes') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={nodeFilterFields}
|
||||
filters={nodeFilters}
|
||||
@@ -733,10 +754,25 @@ export function ServiceDetailGrid({
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button size="sm" onClick={onAddNode}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
section ? undefined : (
|
||||
<Button size="sm" onClick={onAddNode}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
emptyState={
|
||||
section === 'nodes'
|
||||
? {
|
||||
title: 'Нет нод',
|
||||
description: 'Добавьте ноду в пул сервиса.',
|
||||
action: (
|
||||
<Button type="button" variant="outline" onClick={onAddNode}>
|
||||
Добавить ноду
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -745,8 +781,12 @@ export function ServiceDetailGrid({
|
||||
if (tab === 'ssl') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN, ноды и SSL этого сервиса"
|
||||
title={section === 'ssl' ? 'SSL' : 'Активы сервиса'}
|
||||
description={
|
||||
section === 'ssl'
|
||||
? 'Сертификаты привязанных FQDN'
|
||||
: 'IP, FQDN, ноды и SSL этого сервиса'
|
||||
}
|
||||
{...sharedTabs}
|
||||
filterFields={sslFilterFields}
|
||||
filters={sslFilters}
|
||||
@@ -790,8 +830,8 @@ export function ServiceDetailGrid({
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
{...sharedTabs}
|
||||
filterFields={ipFilterFields}
|
||||
filters={ipFilters}
|
||||
|
||||
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
|
||||
interface ServiceIpListProps {
|
||||
ips: string[]
|
||||
ipHealth?: ServiceView['ip_health']
|
||||
healthCheckEnabled?: boolean
|
||||
ipEnabled?: Record<string, boolean>
|
||||
togglingIp?: string | null
|
||||
ipToggleDisabled?: boolean
|
||||
@@ -151,6 +152,7 @@ interface ServiceIpListProps {
|
||||
export function ServiceIpList({
|
||||
ips,
|
||||
ipHealth = [],
|
||||
healthCheckEnabled = true,
|
||||
ipEnabled = {},
|
||||
togglingIp = null,
|
||||
ipToggleDisabled = false,
|
||||
@@ -184,6 +186,10 @@ export function ServiceIpList({
|
||||
{visible.map((ip) => {
|
||||
const health = healthByIp.get(ip)
|
||||
const enabled = ipEnabled[ip] !== false
|
||||
const monitored = healthCheckEnabled && enabled
|
||||
const badgeStatus = monitored
|
||||
? (health?.status ?? 'unknown')
|
||||
: 'disabled'
|
||||
return (
|
||||
<Item
|
||||
key={ip}
|
||||
@@ -192,7 +198,7 @@ export function ServiceIpList({
|
||||
>
|
||||
<ItemMedia>
|
||||
<HealthCheckBadge
|
||||
status={health?.status ?? 'unknown'}
|
||||
status={badgeStatus}
|
||||
latencyMs={health?.latency_ms}
|
||||
lastCheckedAt={health?.last_checked_at}
|
||||
lastError={health?.last_error}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, UnplugIcon } from 'lucide-react'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { ServiceHealthMonitor } from '@/components/reui-kit'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
import type { FailoverHistoryItem } from '@/lib/failover-events'
|
||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||
import {
|
||||
serviceFailoverLogQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
function failoverHistory(items: readonly FailoverLogEntry[]): FailoverHistoryItem[] {
|
||||
return items.map((item) => ({
|
||||
id: `dns:${item.id}`,
|
||||
ip: item.ip,
|
||||
fqdn: item.fqdn,
|
||||
action: item.action,
|
||||
created_at: item.created_at,
|
||||
copy:
|
||||
item.action === 'removed'
|
||||
? `${item.ip} убрана с ${item.fqdn}`
|
||||
: `${item.ip} добавлена на ${item.fqdn}`,
|
||||
source: 'dns',
|
||||
}))
|
||||
}
|
||||
|
||||
function hasLatencySeries(items: readonly HealthLogProbe[]) {
|
||||
return items.some((item) => item.latency_ms != null && item.checked_at.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Health карточки сервиса: график задержки и timeline failover
|
||||
* только по строкам журналов, без синтетических точек.
|
||||
*
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function ServiceHealthSection({ serviceId }: { serviceId: string }) {
|
||||
const id = Number(serviceId)
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const failoverQuery = useQuery(serviceFailoverLogQueryOptions(id))
|
||||
|
||||
const probes = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items])
|
||||
const failoverItems = useMemo(
|
||||
() => failoverQuery.data?.items ?? [],
|
||||
[failoverQuery.data?.items],
|
||||
)
|
||||
const history = useMemo(() => failoverHistory(failoverItems), [failoverItems])
|
||||
const showLatency = hasLatencySeries(probes)
|
||||
|
||||
const enabledProviders = useMemo(
|
||||
() => enabledHealthProviders(viewQuery.data?.domains ?? []),
|
||||
[viewQuery.data?.domains],
|
||||
)
|
||||
const providerStatuses = useMemo(
|
||||
() => providerHealthStatuses(probes, enabledProviders),
|
||||
[probes, enabledProviders],
|
||||
)
|
||||
|
||||
const isLoading = logQuery.isLoading || failoverQuery.isLoading
|
||||
const isError = logQuery.isError || failoverQuery.isError
|
||||
const error = logQuery.error ?? failoverQuery.error
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void logQuery.refetch()
|
||||
void failoverQuery.refetch()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{showLatency ? (
|
||||
<ServiceHealthMonitor
|
||||
items={probes}
|
||||
enabledProviders={enabledProviders}
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isFetching}
|
||||
/>
|
||||
) : (
|
||||
<Frame className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Задержка и ошибки</FrameTitle>
|
||||
<FrameDescription>Проба health-check · последние 24 часа</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет ряда за 24 часа"
|
||||
description="Журнал health-log не содержит замеров задержки. Точки не подставляются."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>История failover</FrameTitle>
|
||||
<FrameDescription>Переключения между нодами</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{history.length > 0 ? (
|
||||
<FailoverTimeline events={[]} history={history} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={UnplugIcon}
|
||||
title="Нет событий failover"
|
||||
description="Журнал failover пуст — timeline строится только по его записям."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { ServiceAssetSection } from '@/components/services/service-asset-section'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { serviceOverviewQueryOptions, serviceViewQueryOptions } from '@/queries'
|
||||
|
||||
const LB_LABEL: Record<ServiceView['lb_mode'], string> = {
|
||||
round_robin: 'Round robin',
|
||||
failover: 'Failover',
|
||||
weighted: 'По весам',
|
||||
}
|
||||
|
||||
interface OverviewRouting {
|
||||
routing_strategy?: string
|
||||
active_addresses?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview: https://reui.io/preview/base/settings-16
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function ServiceRoutingSection({ serviceId }: { serviceId: string }) {
|
||||
const id = Number(serviceId)
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||
const service = viewQuery.data
|
||||
const overview = overviewQuery.data as OverviewRouting | undefined
|
||||
const active = overview?.active_addresses ?? service?.active_ips ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{service ? (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Маршрутизация</FrameTitle>
|
||||
<FrameDescription>Режим балансировки и активные адреса</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<SettingRow title="Режим">
|
||||
<Badge variant="outline" size="sm">
|
||||
{LB_LABEL[service.lb_mode]}
|
||||
</Badge>
|
||||
</SettingRow>
|
||||
<SettingRow title="Стратегия">
|
||||
<span className="text-sm">
|
||||
{overview?.routing_strategy ?? LB_LABEL[service.lb_mode]}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Активные адреса" last>
|
||||
<span className="text-sm font-mono">{active.join(', ') || '—'}</span>
|
||||
</SettingRow>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : null}
|
||||
<ServiceAssetSection serviceId={serviceId} section="ip" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Repeat2Icon,
|
||||
ScaleIcon,
|
||||
ServerIcon,
|
||||
UnplugIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
ServiceIpList,
|
||||
} from '@/components/services/service-fqdn-list'
|
||||
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import { uniqueIpCount } from '@/lib/failover-events'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -75,7 +77,35 @@ const LB_MODE_META: Record<
|
||||
},
|
||||
}
|
||||
|
||||
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||
export function LbModeTile({
|
||||
mode,
|
||||
hasPool = true,
|
||||
}: {
|
||||
mode: LbMode
|
||||
hasPool?: boolean
|
||||
}) {
|
||||
if (!hasPool) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="без резервирования"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<UnplugIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>без резервирования</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = LB_MODE_META[mode]
|
||||
const Icon = meta.icon
|
||||
|
||||
@@ -148,7 +178,10 @@ export function ServiceUnitCard({
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<LbModeTile
|
||||
mode={service.lb_mode}
|
||||
hasPool={uniqueIpCount(service.ips) >= 2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||
@@ -240,6 +273,10 @@ export function ServiceUnitCard({
|
||||
alignWithMenu
|
||||
ips={service.ips ?? []}
|
||||
ipHealth={service.ip_health ?? []}
|
||||
healthCheckEnabled={
|
||||
service.enabled &&
|
||||
(service.domains ?? []).some((domain) => domain.health_check_enabled)
|
||||
}
|
||||
ipEnabled={service.ip_enabled ?? {}}
|
||||
ipToggleDisabled={togglingId === service.id}
|
||||
togglingIp={togglingIp}
|
||||
|
||||
@@ -249,11 +249,11 @@ export function ServicesGroupedCatalog({
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
title="Нет сервисов"
|
||||
description={
|
||||
domainLabel
|
||||
? `Нет сервисов с привязками к ${domainLabel}`
|
||||
: 'Сначала создайте группу, затем добавьте сервисы.'
|
||||
: 'Создайте сервис и добавьте поддомен с нодами.'
|
||||
}
|
||||
action={emptyAction}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { applyAppearance, readContrast, readDensity } from '@/lib/appearance-mode'
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
applyAppearance(readContrast(), readDensity())
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { deriveSystemAttention, type SystemAttention } from '@/lib/system-attention'
|
||||
import {
|
||||
certificatesQueryOptions,
|
||||
certSummaryQueryOptions,
|
||||
domainsListQueryOptions,
|
||||
notificationLogQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
|
||||
type ReadyResponse = {
|
||||
status: string
|
||||
}
|
||||
|
||||
export const SystemAttentionContext = createContext<SystemAttention | null>(null)
|
||||
|
||||
export function useSystemAttentionData(): SystemAttention {
|
||||
const readyQ = useQuery({
|
||||
queryKey: ['ready'],
|
||||
queryFn: async (): Promise<ReadyResponse> => {
|
||||
const res = await fetch('/ready')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<ReadyResponse>
|
||||
},
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: POLL_MS })
|
||||
const certSummaryQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: POLL_MS })
|
||||
const certificatesQ = useQuery({ ...certificatesQueryOptions(), refetchInterval: POLL_MS })
|
||||
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: POLL_MS })
|
||||
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: POLL_MS })
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
deriveSystemAttention({
|
||||
services: servicesQ.data,
|
||||
certificates: certificatesQ.data,
|
||||
certSummary: certSummaryQ.data,
|
||||
notifications: notifyQ.data,
|
||||
domains: domainsQ.data,
|
||||
readyStatus: readyQ.data?.status,
|
||||
readyIsSuccess: readyQ.isSuccess,
|
||||
readyIsError: readyQ.isError,
|
||||
}),
|
||||
[
|
||||
certificatesQ.data,
|
||||
certSummaryQ.data,
|
||||
domainsQ.data,
|
||||
notifyQ.data,
|
||||
readyQ.data?.status,
|
||||
readyQ.isError,
|
||||
readyQ.isSuccess,
|
||||
servicesQ.data,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function useSystemAttention() {
|
||||
const attention = useContext(SystemAttentionContext)
|
||||
if (!attention) {
|
||||
throw new Error('useSystemAttention должен вызываться внутри SystemAttentionProvider')
|
||||
}
|
||||
return attention
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const CONTRAST_KEY = 'cfdm.contrast'
|
||||
const DENSITY_KEY = 'cfdm.density'
|
||||
|
||||
export type ContrastMode = 'standard' | 'high'
|
||||
export type DensityMode = 'normal' | 'compact'
|
||||
|
||||
export function readContrast(): ContrastMode {
|
||||
try {
|
||||
return localStorage.getItem(CONTRAST_KEY) === 'high' ? 'high' : 'standard'
|
||||
} catch {
|
||||
return 'standard'
|
||||
}
|
||||
}
|
||||
|
||||
export function readDensity(): DensityMode {
|
||||
try {
|
||||
return localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'normal'
|
||||
} catch {
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAppearance(contrast: ContrastMode, density: DensityMode) {
|
||||
document.documentElement.dataset.contrast = contrast
|
||||
document.documentElement.dataset.density = density
|
||||
}
|
||||
|
||||
export function writeContrast(contrast: ContrastMode) {
|
||||
try {
|
||||
localStorage.setItem(CONTRAST_KEY, contrast)
|
||||
} catch {
|
||||
// хранилище недоступно
|
||||
}
|
||||
applyAppearance(contrast, readDensity())
|
||||
}
|
||||
|
||||
export function writeDensity(density: DensityMode) {
|
||||
try {
|
||||
localStorage.setItem(DENSITY_KEY, density)
|
||||
} catch {
|
||||
// хранилище недоступно
|
||||
}
|
||||
applyAppearance(readContrast(), density)
|
||||
}
|
||||
@@ -18,6 +18,31 @@ describe('getBreadcrumbs', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the service name on nested service tabs', () => {
|
||||
expect(
|
||||
getBreadcrumbs('/services/9/health', { '/services/9': '[IHOR] Москва' }),
|
||||
).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: '[IHOR] Москва', href: '/services/9' },
|
||||
{ label: 'Health', href: '/services/9/health' },
|
||||
])
|
||||
expect(getBreadcrumbs('/services/9/routing')).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: 'Сервис', href: '/services/9' },
|
||||
{ label: 'Маршрутизация', href: '/services/9/routing' },
|
||||
])
|
||||
expect(getBreadcrumbs('/services/9/nodes')).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: 'Сервис', href: '/services/9' },
|
||||
{ label: 'Ноды', href: '/services/9/nodes' },
|
||||
])
|
||||
expect(getBreadcrumbs('/services/9/subdomains')).toEqual([
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: 'Сервис', href: '/services/9' },
|
||||
{ label: 'Поддомены', href: '/services/9/subdomains' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not reuse the section href for the parent crumb', () => {
|
||||
const crumbs = getBreadcrumbs('/settings/appearance')
|
||||
const hrefs = crumbs.map((crumb) => crumb.href)
|
||||
|
||||
@@ -38,10 +38,24 @@ export function getBreadcrumbs(
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (path.match(/^\/services\/\d+$/)) {
|
||||
const serviceMatch = path.match(
|
||||
/^\/services\/(\d+)(?:\/(nodes|health|routing|subdomains))?$/,
|
||||
)
|
||||
if (serviceMatch) {
|
||||
const servicePath = `/services/${serviceMatch[1]}`
|
||||
const section = serviceMatch[2]
|
||||
const sectionLabels: Record<string, string> = {
|
||||
nodes: 'Ноды',
|
||||
health: 'Health',
|
||||
routing: 'Маршрутизация',
|
||||
subdomains: 'Поддомены',
|
||||
}
|
||||
return [
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
|
||||
{ label: dynamicLabels[servicePath] ?? 'Сервис', href: servicePath },
|
||||
...(section
|
||||
? [{ label: sectionLabels[section] ?? section, href: path }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { bindLiveRefresh, isLiveRefresh, setLiveRefresh } from './live-refresh'
|
||||
|
||||
describe('setLiveRefresh', () => {
|
||||
afterEach(() => {
|
||||
if (isLiveRefresh()) return
|
||||
const client = new QueryClient()
|
||||
setLiveRefresh(client, true)
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('гасит интервал и возвращает его, даже если наблюдатель ставит свой снова', () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
const unbind = bindLiveRefresh(client)
|
||||
const observer = new QueryObserver(client, {
|
||||
queryKey: ['live-refresh'],
|
||||
queryFn: async () => 'ok',
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
const unsubscribe = observer.subscribe(() => {})
|
||||
|
||||
setLiveRefresh(client, false)
|
||||
expect(observer.options.refetchInterval).toBe(false)
|
||||
|
||||
observer.setOptions({
|
||||
queryKey: ['live-refresh'],
|
||||
queryFn: async () => 'ok',
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
expect(observer.options.refetchInterval).toBe(false)
|
||||
|
||||
setLiveRefresh(client, true)
|
||||
expect(observer.options.refetchInterval).toBe(10_000)
|
||||
|
||||
unsubscribe()
|
||||
unbind()
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
const STORAGE_KEY = 'cfdm.live-refresh'
|
||||
|
||||
type RefetchInterval = number | false | undefined | ((query: unknown) => number | false | undefined)
|
||||
|
||||
type IntervalObserver = {
|
||||
options: { refetchInterval?: RefetchInterval }
|
||||
setOptions: (options: IntervalObserver['options']) => void
|
||||
}
|
||||
|
||||
const intendedInterval = new WeakMap<IntervalObserver, RefetchInterval>()
|
||||
|
||||
function readStoredLiveRefresh() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) !== '0'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let liveEnabled = readStoredLiveRefresh()
|
||||
|
||||
export function isLiveRefresh() {
|
||||
return liveEnabled
|
||||
}
|
||||
|
||||
function asObserver(observer: object): IntervalObserver {
|
||||
return observer as IntervalObserver
|
||||
}
|
||||
|
||||
function remember(observer: IntervalObserver) {
|
||||
if (!intendedInterval.has(observer)) {
|
||||
intendedInterval.set(observer, observer.options.refetchInterval)
|
||||
}
|
||||
}
|
||||
|
||||
function syncObserver(observer: IntervalObserver, live: boolean) {
|
||||
remember(observer)
|
||||
const next = live ? intendedInterval.get(observer) : false
|
||||
if (Object.is(observer.options.refetchInterval, next)) return
|
||||
observer.setOptions({ ...observer.options, refetchInterval: next })
|
||||
}
|
||||
|
||||
function applyLiveRefresh(queryClient: QueryClient, live: boolean) {
|
||||
for (const query of queryClient.getQueryCache().getAll()) {
|
||||
for (const observer of query.observers) {
|
||||
syncObserver(asObserver(observer), live)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Пауза и возобновление уже заданных refetchInterval. Новые запросы без интервала не запускают опрос. */
|
||||
export function setLiveRefresh(queryClient: QueryClient, enabled: boolean) {
|
||||
liveEnabled = enabled
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
|
||||
} catch {
|
||||
// приватный режим без хранилища — переключатель живёт до перезагрузки
|
||||
}
|
||||
applyLiveRefresh(queryClient, enabled)
|
||||
}
|
||||
|
||||
export function bindLiveRefresh(queryClient: QueryClient) {
|
||||
applyLiveRefresh(queryClient, liveEnabled)
|
||||
return queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type !== 'observerAdded' && event.type !== 'observerOptionsUpdated') return
|
||||
syncObserver(asObserver(event.observer), liveEnabled)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_BINDING_HEALTH,
|
||||
addAddressNode,
|
||||
addCommonFqdn,
|
||||
addExtraFqdn,
|
||||
emptyAddressBlock,
|
||||
emptyBindingDraft,
|
||||
hydrateAddressBlock,
|
||||
@@ -45,8 +46,8 @@ describe('hydrateAddressBlock', () => {
|
||||
|
||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
|
||||
{ ip: '185.244.181.61', extraFqdn: '' },
|
||||
{ ip: '93.115.203.183', extraFqdns: ['msk.rutg.rkns.top'] },
|
||||
{ ip: '185.244.181.61', extraFqdns: [] },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
@@ -66,7 +67,7 @@ describe('hydrateAddressBlock', () => {
|
||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
|
||||
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
|
||||
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
|
||||
})
|
||||
|
||||
@@ -78,12 +79,46 @@ describe('hydrateAddressBlock', () => {
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
|
||||
|
||||
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
|
||||
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdns: [] }])
|
||||
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||
expect(state.preservedBindings).toHaveLength(1)
|
||||
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
||||
})
|
||||
|
||||
it('при одном IP пула отделяет второй A в extraFqdn узла', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('кладёт несколько extra A на один IP в extraFqdns, включая wildcard', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{
|
||||
ip: '130.49.213.176',
|
||||
extraFqdns: ['ndns.shnt.top', '*.mdns.shnt.top'],
|
||||
},
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('поднимает веса и приоритеты с общего FQDN', () => {
|
||||
const drafts = [
|
||||
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
|
||||
@@ -101,7 +136,47 @@ describe('hydrateAddressBlock', () => {
|
||||
'130.49.213.153': 2,
|
||||
'93.115.203.183': 1,
|
||||
})
|
||||
expect(state.nodes[0]?.extraFqdn).toBe('nsgt.rkns.top')
|
||||
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
|
||||
})
|
||||
|
||||
it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => {
|
||||
const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183']
|
||||
const drafts = [
|
||||
// corrupted common — missing one pool IP
|
||||
aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']),
|
||||
aRecord('gt.rkns.top', pool),
|
||||
aRecord('nsgt.rkns.top', ['130.49.213.176']),
|
||||
aRecord('rutg.rkns.top', ['93.115.203.183']),
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, pool)
|
||||
|
||||
expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '130.49.213.153', extraFqdns: [] },
|
||||
{ ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] },
|
||||
{ ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
|
||||
const payload = toDomainsPayload(state, primaryMeta)
|
||||
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||
'gw.pngs.top',
|
||||
'gt.rkns.top',
|
||||
'nsgt.rkns.top',
|
||||
'rutg.rkns.top',
|
||||
])
|
||||
expect(payload[0]?.target_ips).toEqual(pool)
|
||||
expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4)
|
||||
})
|
||||
|
||||
it('не дублирует FQDN при повторном binding в drafts', () => {
|
||||
const pool = ['1.1.1.1', '2.2.2.2']
|
||||
const state = hydrateAddressBlock(
|
||||
[aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)],
|
||||
pool,
|
||||
)
|
||||
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,6 +218,40 @@ describe('toDomainsPayload', () => {
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('круг hydrate → payload → hydrate сохраняет extra FQDN при одном IP', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
expect(first.commonFqdns).toEqual(['dns.shnt.top'])
|
||||
expect(first.nodes).toEqual([
|
||||
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
|
||||
])
|
||||
const rebound = toAddressBindings(first, primaryMeta)
|
||||
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
|
||||
|
||||
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('круг hydrate → payload → hydrate сохраняет несколько extra и wildcard при одном IP', () => {
|
||||
const drafts = [
|
||||
aRecord('dns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('ndns.shnt.top', ['130.49.213.176']),
|
||||
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
|
||||
]
|
||||
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
|
||||
expect(first.nodes[0]?.extraFqdns).toEqual(['ndns.shnt.top', '*.mdns.shnt.top'])
|
||||
const rebound = toAddressBindings(first, primaryMeta)
|
||||
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
|
||||
|
||||
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAddressNode', () => {
|
||||
@@ -158,7 +267,7 @@ describe('removeAddressNode', () => {
|
||||
|
||||
const next = removeAddressNode(state, '10.0.0.1')
|
||||
|
||||
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
|
||||
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdns: [] }])
|
||||
expect(next.preservedBindings).toHaveLength(1)
|
||||
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
|
||||
})
|
||||
@@ -167,7 +276,7 @@ describe('removeAddressNode', () => {
|
||||
describe('addAddressNode / addCommonFqdn', () => {
|
||||
it('не добавляет дубликат IP', () => {
|
||||
const withIp = addAddressNode(
|
||||
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
|
||||
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdns: [] }] },
|
||||
'1.1.1.1',
|
||||
)
|
||||
expect(withIp.nodes).toHaveLength(1)
|
||||
@@ -180,15 +289,22 @@ describe('addAddressNode / addCommonFqdn', () => {
|
||||
)
|
||||
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
|
||||
})
|
||||
|
||||
it('добавляет extra FQDN к IP и отклоняет дубликат', () => {
|
||||
const withIp = addAddressNode(emptyAddressBlock(), '1.1.1.1')
|
||||
const withExtra = addExtraFqdn(withIp, '1.1.1.1', 'mdns.shnt.top')
|
||||
expect(withExtra.nodes[0]?.extraFqdns).toEqual(['mdns.shnt.top'])
|
||||
expect(addExtraFqdn(withExtra, '1.1.1.1', 'MDNS.shnt.top')).toBe(withExtra)
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchAddressIpMeta', () => {
|
||||
it('меняет вес одного IP и не трогает extraFqdn', () => {
|
||||
it('меняет вес одного IP и не трогает extraFqdns', () => {
|
||||
const state = {
|
||||
...addAddressNode(addAddressNode(emptyAddressBlock(), '1.1.1.1'), '2.2.2.2'),
|
||||
nodes: [
|
||||
{ ip: '1.1.1.1', extraFqdn: 'msk.example.com' },
|
||||
{ ip: '2.2.2.2', extraFqdn: '' },
|
||||
{ ip: '1.1.1.1', extraFqdns: ['msk.example.com'] },
|
||||
{ ip: '2.2.2.2', extraFqdns: [] },
|
||||
],
|
||||
}
|
||||
const next = patchAddressIpMeta(state, '1.1.1.1', { weight: 7 })
|
||||
@@ -224,7 +340,7 @@ describe('CNAME / preservedBindings', () => {
|
||||
cname,
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
|
||||
expect(state.nodes[0]?.extraFqdns).toEqual(['msk.rkns.top'])
|
||||
expect(state.preservedBindings).toHaveLength(1)
|
||||
|
||||
const payload = toDomainsPayload(state, primaryMeta)
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ServiceBindingDraft {
|
||||
|
||||
export interface AddressNode {
|
||||
ip: string
|
||||
extraFqdn: string
|
||||
extraFqdns: string[]
|
||||
}
|
||||
|
||||
export interface AddressBlockState {
|
||||
@@ -113,6 +113,10 @@ function sameIpSet(left: string[], right: string[]): boolean {
|
||||
return right.every((ip) => set.has(ip.trim()))
|
||||
}
|
||||
|
||||
function fqdnKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
@@ -145,6 +149,36 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
|
||||
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
||||
}
|
||||
|
||||
/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */
|
||||
function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set<string>): boolean {
|
||||
if (draft.record_type !== 'A') return false
|
||||
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
|
||||
if (ips.length < 2) return false
|
||||
return ips.every((ip) => poolSet.has(ip))
|
||||
}
|
||||
|
||||
function takeAsCommon(
|
||||
draft: ServiceBindingDraft,
|
||||
fqdn: string,
|
||||
commonFqdns: string[],
|
||||
seenCommon: Set<string>,
|
||||
weights: Record<string, number>,
|
||||
priorities: Record<string, number>,
|
||||
): { weights: Record<string, number>; priorities: Record<string, number> } {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (fqdn && key && !seenCommon.has(key)) {
|
||||
seenCommon.add(key)
|
||||
commonFqdns.push(draft.fqdn)
|
||||
}
|
||||
if (Object.keys(weights).length === 0) {
|
||||
return {
|
||||
weights: { ...draft.target_ip_weights },
|
||||
priorities: { ...draft.target_ip_priorities },
|
||||
}
|
||||
}
|
||||
return { weights, priorities }
|
||||
}
|
||||
|
||||
export function hydrateAddressBlock(
|
||||
drafts: ServiceBindingDraft[],
|
||||
pool: string[] = [],
|
||||
@@ -161,27 +195,73 @@ export function hydrateAddressBlock(
|
||||
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
||||
const poolSet = new Set(ips)
|
||||
const commonFqdns: string[] = []
|
||||
const claimed = new Set<string>()
|
||||
const extraByIp = new Map<string, string>()
|
||||
const seenCommon = new Set<string>()
|
||||
const seenExtra = new Set<string>()
|
||||
const extraByIp = new Map<string, string[]>()
|
||||
const preservedBindings: ServiceBindingDraft[] = []
|
||||
let weights: Record<string, number> = {}
|
||||
let priorities: Record<string, number> = {}
|
||||
const splitSinglePool =
|
||||
ips.length === 1 &&
|
||||
drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))
|
||||
.length > 1
|
||||
let assignedFirstSinglePoolCommon = false
|
||||
|
||||
function pushExtra(ip: string, fqdn: string) {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (!key || seenExtra.has(key) || seenCommon.has(key)) return
|
||||
seenExtra.add(key)
|
||||
const list = extraByIp.get(ip) ?? []
|
||||
list.push(fqdn)
|
||||
extraByIp.set(ip, list)
|
||||
}
|
||||
|
||||
function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (key && seenExtra.has(key)) {
|
||||
seenExtra.delete(key)
|
||||
for (const [ip, list] of extraByIp) {
|
||||
extraByIp.set(
|
||||
ip,
|
||||
list.filter((item) => fqdnKey(item) !== key),
|
||||
)
|
||||
}
|
||||
}
|
||||
const next = takeAsCommon(
|
||||
draft,
|
||||
fqdn,
|
||||
commonFqdns,
|
||||
seenCommon,
|
||||
weights,
|
||||
priorities,
|
||||
)
|
||||
weights = next.weights
|
||||
priorities = next.priorities
|
||||
}
|
||||
|
||||
for (const draft of drafts) {
|
||||
const fqdn = draft.fqdn.trim()
|
||||
if (isFullPoolA(draft, ips)) {
|
||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
||||
if (Object.keys(weights).length === 0) {
|
||||
weights = { ...draft.target_ip_weights }
|
||||
priorities = { ...draft.target_ip_priorities }
|
||||
if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) {
|
||||
if (!assignedFirstSinglePoolCommon) {
|
||||
assignedFirstSinglePoolCommon = true
|
||||
promoteToCommon(draft, fqdn)
|
||||
continue
|
||||
}
|
||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||
if (ip && poolSet.has(ip) && fqdn) {
|
||||
pushExtra(ip, draft.fqdn)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage).
|
||||
if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) {
|
||||
promoteToCommon(draft, fqdn)
|
||||
continue
|
||||
}
|
||||
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||
if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) {
|
||||
claimed.add(ip)
|
||||
extraByIp.set(ip, draft.fqdn)
|
||||
if (ip && poolSet.has(ip) && fqdn) {
|
||||
pushExtra(ip, draft.fqdn)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -192,7 +272,7 @@ export function hydrateAddressBlock(
|
||||
commonFqdns,
|
||||
nodes: ips.map((ip) => ({
|
||||
ip,
|
||||
extraFqdn: extraByIp.get(ip) ?? '',
|
||||
extraFqdns: extraByIp.get(ip) ?? [],
|
||||
})),
|
||||
preservedBindings,
|
||||
target_ip_weights: weights,
|
||||
@@ -237,7 +317,7 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
|
||||
nodes: [...state.nodes, { ip: trimmed, extraFqdns: [] }],
|
||||
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
|
||||
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
|
||||
}
|
||||
@@ -270,22 +350,33 @@ export function patchAddressIpMeta(
|
||||
}
|
||||
}
|
||||
|
||||
function fqdnKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (!key) return false
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
|
||||
if (state.nodes.some((node) => fqdnKey(node.extraFqdn) === key)) return true
|
||||
return false
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return true
|
||||
}
|
||||
return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key)
|
||||
}
|
||||
|
||||
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
||||
const trimmed = fqdn.trim()
|
||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
||||
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
|
||||
if (!trimmed) return state
|
||||
const key = fqdnKey(trimmed)
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return state
|
||||
}
|
||||
// Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates).
|
||||
const preservedBindings = state.preservedBindings.filter(
|
||||
(item) => fqdnKey(item.fqdn) !== key,
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
commonFqdns: [...state.commonFqdns, trimmed],
|
||||
preservedBindings,
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
||||
@@ -306,6 +397,62 @@ export function updateCommonFqdn(
|
||||
}
|
||||
}
|
||||
|
||||
export function addExtraFqdn(
|
||||
state: AddressBlockState,
|
||||
ip: string,
|
||||
fqdn: string,
|
||||
): AddressBlockState {
|
||||
const trimmed = fqdn.trim()
|
||||
if (!trimmed) return state
|
||||
const key = fqdnKey(trimmed)
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return state
|
||||
}
|
||||
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||
return {
|
||||
...state,
|
||||
preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key),
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function removeExtraFqdn(
|
||||
state: AddressBlockState,
|
||||
ip: string,
|
||||
index: number,
|
||||
): AddressBlockState {
|
||||
return {
|
||||
...state,
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.ip === ip
|
||||
? { ...node, extraFqdns: node.extraFqdns.filter((_, i) => i !== index) }
|
||||
: node,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateExtraFqdn(
|
||||
state: AddressBlockState,
|
||||
ip: string,
|
||||
index: number,
|
||||
fqdn: string,
|
||||
): AddressBlockState {
|
||||
return {
|
||||
...state,
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.ip === ip
|
||||
? {
|
||||
...node,
|
||||
extraFqdns: node.extraFqdns.map((item, i) => (i === index ? fqdn : item)),
|
||||
}
|
||||
: node,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAddressBindings(
|
||||
state: AddressBlockState,
|
||||
primary: AddressPrimaryMeta,
|
||||
@@ -335,21 +482,29 @@ export function toAddressBindings(
|
||||
}
|
||||
|
||||
for (const node of state.nodes) {
|
||||
const extraFqdn = node.extraFqdn.trim()
|
||||
if (!extraFqdn) continue
|
||||
drafts.push({
|
||||
fqdn: extraFqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [node.ip],
|
||||
target_cname: '',
|
||||
lb_mode: primary.lb_mode,
|
||||
health: { ...primary.health },
|
||||
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||
})
|
||||
for (const raw of node.extraFqdns) {
|
||||
const extraFqdn = raw.trim()
|
||||
if (!extraFqdn) continue
|
||||
drafts.push({
|
||||
fqdn: extraFqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [node.ip],
|
||||
target_cname: '',
|
||||
lb_mode: primary.lb_mode,
|
||||
health: { ...primary.health },
|
||||
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
drafts.push(...state.preservedBindings)
|
||||
const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean))
|
||||
for (const preserved of state.preservedBindings) {
|
||||
const key = fqdnKey(preserved.fqdn)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
drafts.push(preserved)
|
||||
}
|
||||
return drafts
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { deriveSystemAttention, isCertExpiringWithin } from './system-attention'
|
||||
|
||||
const NOW = Date.parse('2026-11-02T00:00:00.000Z')
|
||||
|
||||
function inDays(days: number) {
|
||||
return new Date(NOW + days * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
describe('deriveSystemAttention', () => {
|
||||
it('считает Down и деградацию в группах и без группы', () => {
|
||||
const attention = deriveSystemAttention({
|
||||
now: NOW,
|
||||
readyIsSuccess: true,
|
||||
readyIsError: false,
|
||||
readyStatus: 'ready',
|
||||
services: {
|
||||
groups: [
|
||||
{
|
||||
services: [
|
||||
{ id: 1, name: 'Альфа', health_status: 'down' },
|
||||
{ id: 2, name: 'Бета', health_status: 'up' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ id: 3, name: 'Гамма', health_status: 'degraded' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(attention.servicesDown).toBe(1)
|
||||
expect(attention.servicesDegraded).toBe(1)
|
||||
expect(attention.incidents.map((item) => item.title)).toEqual(['Альфа', 'Гамма'])
|
||||
expect(attention.incidents[0]).toMatchObject({
|
||||
tone: 'destructive',
|
||||
href: { to: '/services/$serviceId', serviceId: '1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('берёт сертификаты с остатком не больше 14 дней, включая истёкшие', () => {
|
||||
expect(isCertExpiringWithin(inDays(14), 14, NOW)).toBe(true)
|
||||
expect(isCertExpiringWithin(inDays(14.01), 14, NOW)).toBe(false)
|
||||
expect(isCertExpiringWithin(inDays(-1), 14, NOW)).toBe(true)
|
||||
|
||||
const attention = deriveSystemAttention({
|
||||
now: NOW,
|
||||
readyIsSuccess: false,
|
||||
readyIsError: false,
|
||||
certificates: [
|
||||
{ id: 10, hostname: 'soon.example', expires_at: inDays(3), service_id: 7 },
|
||||
{ id: 11, hostname: 'later.example', expires_at: inDays(20), service_id: 7 },
|
||||
{ id: 12, hostname: 'dead.example', expires_at: inDays(-2), service_id: null },
|
||||
{ id: 13, hostname: 'unknown.example', expires_at: null },
|
||||
],
|
||||
certSummary: [
|
||||
['warning', 2],
|
||||
['expired', 1],
|
||||
['ok', 4],
|
||||
],
|
||||
notifications: [
|
||||
{ kind: 'sync', title: 'Sync failed', message: 'timeout' },
|
||||
{ kind: 'info', title: 'Готово', message: 'ok' },
|
||||
],
|
||||
domains: [{ group_id: null }, { group_id: 1 }],
|
||||
})
|
||||
|
||||
expect(attention.certsExpiring).toBe(2)
|
||||
expect(attention.certWarnings).toBe(3)
|
||||
expect(attention.failedJobs).toBe(1)
|
||||
expect(attention.ungroupedCount).toBe(1)
|
||||
expect(attention.incidents.map((item) => item.title)).toEqual([
|
||||
'dead.example',
|
||||
'soon.example',
|
||||
])
|
||||
expect(attention.incidents[0]?.href).toEqual({ to: '/certificates' })
|
||||
expect(attention.incidents[1]?.href).toEqual({
|
||||
to: '/services/$serviceId',
|
||||
serviceId: '7',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
/** Общее окно «истекает» для точек сайдбара и инцидентов монитора. */
|
||||
export const CERT_EXPIRING_WITHIN_DAYS = 14
|
||||
|
||||
const MS_PER_DAY = 86_400_000
|
||||
|
||||
export type AttentionService = {
|
||||
id: number
|
||||
name: string
|
||||
health_status: string
|
||||
}
|
||||
|
||||
export type AttentionServiceGroups = {
|
||||
groups: { services: AttentionService[] }[]
|
||||
ungrouped: AttentionService[]
|
||||
}
|
||||
|
||||
export type AttentionCertificate = {
|
||||
id: number
|
||||
hostname: string
|
||||
expires_at: string | null
|
||||
service_id?: number | null
|
||||
}
|
||||
|
||||
export type AttentionNotification = {
|
||||
kind: string
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AttentionDomain = {
|
||||
group_id: number | null
|
||||
}
|
||||
|
||||
export type SystemIncident = {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
tone: 'destructive' | 'warning'
|
||||
href:
|
||||
| { to: '/services/$serviceId'; serviceId: string }
|
||||
| { to: '/certificates' }
|
||||
}
|
||||
|
||||
export type SystemAttention = {
|
||||
servicesDown: number
|
||||
servicesDegraded: number
|
||||
certWarnings: number
|
||||
certsExpiring: number
|
||||
failedJobs: number
|
||||
ungroupedCount: number
|
||||
readyOk: boolean
|
||||
readyDegraded: boolean
|
||||
incidents: SystemIncident[]
|
||||
}
|
||||
|
||||
type DeriveInput = {
|
||||
services?: AttentionServiceGroups
|
||||
certificates?: AttentionCertificate[]
|
||||
certSummary?: [string, number][]
|
||||
notifications?: AttentionNotification[]
|
||||
domains?: AttentionDomain[]
|
||||
readyStatus?: string
|
||||
readyIsSuccess: boolean
|
||||
readyIsError: boolean
|
||||
now?: number
|
||||
}
|
||||
|
||||
function countByStatus(
|
||||
summary: [string, number][] | undefined,
|
||||
statuses: string[],
|
||||
) {
|
||||
if (!summary) return 0
|
||||
return summary
|
||||
.filter(([status]) => statuses.includes(status))
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
}
|
||||
|
||||
function daysUntilExpiry(expiresAt: string, now: number): number | null {
|
||||
const ts = new Date(expiresAt).getTime()
|
||||
if (Number.isNaN(ts)) return null
|
||||
return (ts - now) / MS_PER_DAY
|
||||
}
|
||||
|
||||
/** Истёкшие тоже входят: дней до срока ≤ 14, включая отрицательные. */
|
||||
export function isCertExpiringWithin(
|
||||
expiresAt: string | null,
|
||||
withinDays: number,
|
||||
now: number,
|
||||
) {
|
||||
if (!expiresAt) return false
|
||||
const days = daysUntilExpiry(expiresAt, now)
|
||||
return days != null && days <= withinDays
|
||||
}
|
||||
|
||||
function eachService(
|
||||
data: AttentionServiceGroups | undefined,
|
||||
visit: (service: AttentionService) => void,
|
||||
) {
|
||||
if (!data) return
|
||||
for (const group of data.groups) {
|
||||
for (const service of group.services) visit(service)
|
||||
}
|
||||
for (const service of data.ungrouped) visit(service)
|
||||
}
|
||||
|
||||
function certDetail(days: number) {
|
||||
if (days < 0) return 'Истёк'
|
||||
if (days < 1) return 'Сегодня'
|
||||
return `${Math.ceil(days)} дн.`
|
||||
}
|
||||
|
||||
function compareRu(a: string, b: string) {
|
||||
return a.localeCompare(b, 'ru')
|
||||
}
|
||||
|
||||
export function deriveSystemAttention(input: DeriveInput): SystemAttention {
|
||||
const now = input.now ?? Date.now()
|
||||
let servicesDown = 0
|
||||
let servicesDegraded = 0
|
||||
const serviceIncidents: SystemIncident[] = []
|
||||
|
||||
eachService(input.services, (service) => {
|
||||
if (service.health_status === 'down') {
|
||||
servicesDown += 1
|
||||
serviceIncidents.push({
|
||||
id: `service:${service.id}`,
|
||||
title: service.name || `Сервис ${service.id}`,
|
||||
detail: 'Недоступен',
|
||||
tone: 'destructive',
|
||||
href: { to: '/services/$serviceId', serviceId: String(service.id) },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (service.health_status === 'degraded') {
|
||||
servicesDegraded += 1
|
||||
serviceIncidents.push({
|
||||
id: `service:${service.id}`,
|
||||
title: service.name || `Сервис ${service.id}`,
|
||||
detail: 'Деградация',
|
||||
tone: 'warning',
|
||||
href: { to: '/services/$serviceId', serviceId: String(service.id) },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
serviceIncidents.sort((a, b) => {
|
||||
if (a.tone !== b.tone) return a.tone === 'destructive' ? -1 : 1
|
||||
return compareRu(a.title, b.title)
|
||||
})
|
||||
|
||||
const certIncidents: { incident: SystemIncident; days: number }[] = []
|
||||
for (const cert of input.certificates ?? []) {
|
||||
if (!cert.expires_at) continue
|
||||
const days = daysUntilExpiry(cert.expires_at, now)
|
||||
if (days == null || days > CERT_EXPIRING_WITHIN_DAYS) continue
|
||||
const serviceId = cert.service_id
|
||||
certIncidents.push({
|
||||
days,
|
||||
incident: {
|
||||
id: `cert:${cert.id}`,
|
||||
title: cert.hostname,
|
||||
detail: certDetail(days),
|
||||
tone: days < 0 ? 'destructive' : 'warning',
|
||||
href:
|
||||
serviceId != null
|
||||
? { to: '/services/$serviceId', serviceId: String(serviceId) }
|
||||
: { to: '/certificates' },
|
||||
},
|
||||
})
|
||||
}
|
||||
certIncidents.sort((a, b) => a.days - b.days || compareRu(a.incident.title, b.incident.title))
|
||||
|
||||
const failedJobs = (input.notifications ?? []).filter((entry) =>
|
||||
/fail|error/i.test(`${entry.kind} ${entry.title} ${entry.message}`),
|
||||
).length
|
||||
|
||||
return {
|
||||
servicesDown,
|
||||
servicesDegraded,
|
||||
certWarnings: countByStatus(input.certSummary, ['warning', 'expired', 'error']),
|
||||
certsExpiring: certIncidents.length,
|
||||
failedJobs,
|
||||
ungroupedCount: (input.domains ?? []).filter((domain) => domain.group_id == null).length,
|
||||
readyOk: input.readyStatus === 'ready' && input.readyIsSuccess,
|
||||
readyDegraded: input.readyStatus === 'degraded' || input.readyIsError,
|
||||
incidents: [...serviceIncidents, ...certIncidents.map((item) => item.incident)],
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Toaster } from '@cfdm/ui/components/sonner'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { queryClient } from './lib/queryClient'
|
||||
import '@cfdm/ui/globals.css'
|
||||
import '@/styles/appearance-modes.css'
|
||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
||||
|
||||
debugAgentLog('main.tsx:boot', 'app boot', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { queryOptions, type QueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
failoverLogSchema,
|
||||
@@ -210,3 +210,17 @@ export async function createOriginHealthCheck(body: Record<string, unknown>) {
|
||||
export async function listOriginHealthChecks() {
|
||||
return api.get('/api/v1/health-checks')
|
||||
}
|
||||
|
||||
export function invalidateServiceDetail(queryClient: QueryClient, id: number) {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
|
||||
])
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { CircleAlertIcon, SearchIcon, ShieldCheckIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { sqliteUtcToIso } from '@/lib/format'
|
||||
import type { Certificate } from '@/lib/schemas'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { IncidentAlert } from '@/components/incident-alert'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { CertKpiStats } from '@/components/cert-kpi-stats'
|
||||
import {
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
useCertFilterFields,
|
||||
} from '@/components/columns/certificates-columns'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/certificates')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -29,6 +34,7 @@ export const Route = createFileRoute('/_auth/certificates')({
|
||||
})
|
||||
|
||||
function CertificatesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultCertFilters)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const queryClient = useQueryClient()
|
||||
@@ -59,6 +65,7 @@ function CertificatesPage() {
|
||||
|
||||
const filterFields = useCertFilterFields()
|
||||
const columns = useCertificateColumns()
|
||||
const expiredCert = useMemo(() => pickHeaviestExpired(certs), [certs])
|
||||
|
||||
const primaryAction = (
|
||||
<LoadingButton
|
||||
@@ -66,7 +73,7 @@ function CertificatesPage() {
|
||||
isLoading={checkMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
>
|
||||
Запустить проверку
|
||||
Проверить все
|
||||
</LoadingButton>
|
||||
)
|
||||
|
||||
@@ -77,6 +84,13 @@ function CertificatesPage() {
|
||||
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||
actions={primaryAction}
|
||||
/>
|
||||
{expiredCert ? (
|
||||
<IncidentAlert
|
||||
title={expiredCertTitle(expiredCert)}
|
||||
description={expiredCertDescription(expiredCert)}
|
||||
onShow={() => showCertificate(expiredCert, navigate, setActiveTab)}
|
||||
/>
|
||||
) : null}
|
||||
<CertKpiStats
|
||||
summary={summary}
|
||||
total={certs?.length}
|
||||
@@ -104,12 +118,97 @@ function CertificatesPage() {
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
errorState={{
|
||||
icon: CircleAlertIcon,
|
||||
title: 'Не удалось загрузить сертификаты',
|
||||
description: error?.message ?? 'Повторите запрос.',
|
||||
action: (
|
||||
<Button type="button" variant="outline" onClick={() => refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
filteredEmptyState={{
|
||||
icon: SearchIcon,
|
||||
title: 'Нет совпадений',
|
||||
description: 'Измените поиск или сбросьте фильтры.',
|
||||
resetLabel: 'Сбросить фильтры',
|
||||
}}
|
||||
onFilteredEmptyReset={() => setActiveTab('all')}
|
||||
emptyState={{
|
||||
title: 'Сертификаты не найдены',
|
||||
description: 'Сертификаты появятся после проверки доменов',
|
||||
action: primaryAction,
|
||||
icon: ShieldCheckIcon,
|
||||
title: 'Нет данных',
|
||||
description: 'Сертификаты появятся после первой проверки сервисов.',
|
||||
action: (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending}
|
||||
>
|
||||
Проверить
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function pickHeaviestExpired(
|
||||
certs: Certificate[] | undefined,
|
||||
): Certificate | null {
|
||||
const expired = (certs ?? []).filter((cert) => cert.status === 'expired')
|
||||
if (expired.length === 0) return null
|
||||
return [...expired].sort((left, right) => {
|
||||
const delta = expiryMs(left.expires_at) - expiryMs(right.expires_at)
|
||||
if (delta !== 0) return delta
|
||||
return left.hostname.localeCompare(right.hostname, 'ru')
|
||||
})[0]
|
||||
}
|
||||
|
||||
function expiryMs(value: string | null): number {
|
||||
if (!value) return Number.POSITIVE_INFINITY
|
||||
const ms = new Date(sqliteUtcToIso(value) ?? value).getTime()
|
||||
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms
|
||||
}
|
||||
|
||||
function expiredCertTitle(cert: Certificate): string {
|
||||
const ms = expiryMs(cert.expires_at)
|
||||
if (!Number.isFinite(ms)) return `Сертификат ${cert.hostname} истёк.`
|
||||
const days = Math.floor((Date.now() - ms) / 86_400_000)
|
||||
if (days <= 0) return `Сертификат ${cert.hostname} истёк.`
|
||||
return `Сертификат ${cert.hostname} истёк ${days} ${pluralDays(days)} назад.`
|
||||
}
|
||||
|
||||
function expiredCertDescription(cert: Certificate): string | undefined {
|
||||
const parts = [
|
||||
cert.service_name ? `Сервис ${cert.service_name}` : null,
|
||||
cert.last_error,
|
||||
].filter((part): part is string => Boolean(part))
|
||||
if (parts.length === 0) return undefined
|
||||
return `${parts.join('. ')}.`
|
||||
}
|
||||
|
||||
function pluralDays(days: number): string {
|
||||
const mod10 = days % 10
|
||||
const mod100 = days % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return 'день'
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'дня'
|
||||
return 'дней'
|
||||
}
|
||||
|
||||
function showCertificate(
|
||||
cert: Certificate,
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
setActiveTab: (tabId: string) => void,
|
||||
): void {
|
||||
if (cert.service_id != null) {
|
||||
navigate({
|
||||
to: '/services/$serviceId',
|
||||
params: { serviceId: String(cert.service_id) },
|
||||
})
|
||||
return
|
||||
}
|
||||
setActiveTab('expired')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
@@ -10,7 +10,8 @@ import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, domainServiceBindingsQueryOptions, subdomainKeys } from '@/queries'
|
||||
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome'
|
||||
import {
|
||||
DNS_TABS,
|
||||
createDefaultDnsFilters,
|
||||
@@ -154,77 +155,63 @@ function DnsPage() {
|
||||
return list.filter((r) => r.name.toLowerCase().includes(query))
|
||||
}, [records, host])
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать с Cloudflare
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||
description={
|
||||
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
|
||||
}
|
||||
actions={headerActions}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/domains/$domainId" params={{ domainId }} />
|
||||
}
|
||||
>
|
||||
← Обзор домена
|
||||
</Button>
|
||||
</DetailPanel.Header>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<ResourcePage
|
||||
title="DNS-записи"
|
||||
description={domain?.zone_name ?? ''}
|
||||
hideHeader
|
||||
tabs={DNS_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={dnsTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultDnsFilters())}
|
||||
getFilterFieldValue={dnsFilterFieldValue}
|
||||
columns={columns}
|
||||
data={displayRecords}
|
||||
getRowId={(row) => String(row.id)}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchDomain()
|
||||
void refetchRecords()
|
||||
}}
|
||||
emptyState={{
|
||||
title: 'DNS-записи не найдены',
|
||||
description: 'Синхронизируйте зону или создайте запись вручную',
|
||||
action: (
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
<DomainDetailChrome domainId={domainId} activeTab="dns">
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{host ? (
|
||||
<p className="text-muted-foreground text-sm">Фильтр по хосту: {host}</p>
|
||||
) : null}
|
||||
<ResourcePage
|
||||
title="DNS-записи"
|
||||
description={domain?.zone_name ?? ''}
|
||||
hideHeader
|
||||
tabs={DNS_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={dnsTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultDnsFilters())}
|
||||
getFilterFieldValue={dnsFilterFieldValue}
|
||||
columns={columns}
|
||||
data={displayRecords}
|
||||
getRowId={(row) => String(row.id)}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchDomain()
|
||||
void refetchRecords()
|
||||
}}
|
||||
toolbarExtra={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать с Cloudflare
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'DNS-записи не найдены',
|
||||
description: 'Синхронизируйте зону или создайте запись вручную',
|
||||
action: (
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DomainDetailChrome>
|
||||
|
||||
<FormSheet
|
||||
open={sheetOpen}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
Link2Icon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
domainDetailQueryOptions,
|
||||
domainServiceBindingsQueryOptions,
|
||||
healthStatusKeys,
|
||||
runHealthCheck,
|
||||
serviceGroupsQueryOptions,
|
||||
servicesQueryOptions,
|
||||
subdomainsListQueryOptions,
|
||||
@@ -21,9 +11,7 @@ import {
|
||||
import { useDomainPage } from '@/hooks/use-domain-page'
|
||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
SUBDOMAIN_TABS,
|
||||
createDefaultSubdomainFilters,
|
||||
@@ -38,15 +26,23 @@ import {
|
||||
} from '@/components/subdomain-edit-sheet'
|
||||
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
||||
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome'
|
||||
import { type DomainSectionTab } from '@/components/domains/domain-detail-tabs'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
validateSearch: (search: Record<string, unknown>): { tab?: DomainSectionTab } => {
|
||||
const tab = search.tab
|
||||
if (
|
||||
tab === 'availability' ||
|
||||
tab === 'subdomains' ||
|
||||
tab === 'bindings' ||
|
||||
tab === 'overview'
|
||||
) {
|
||||
return { tab }
|
||||
}
|
||||
return {}
|
||||
},
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.domainId)
|
||||
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
|
||||
@@ -61,70 +57,28 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
component: DomainOverviewPage,
|
||||
})
|
||||
|
||||
function DomainPageSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TableSkeleton rows={1} cols={2} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
</div>
|
||||
<TableSkeleton rows={5} cols={5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainOverviewPage() {
|
||||
const { domainId } = Route.useParams()
|
||||
const { tab } = Route.useSearch()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
const {
|
||||
domain,
|
||||
bindings,
|
||||
services,
|
||||
serviceGroupById,
|
||||
subdomainRows,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
syncMutation,
|
||||
createSubdomainMutation,
|
||||
bindings,
|
||||
updateSubdomainMutation,
|
||||
deleteSubdomainMutation,
|
||||
linkServiceMutation,
|
||||
} = useDomainPage(id)
|
||||
|
||||
const runCheckMutation = useMutation({
|
||||
mutationFn: runHealthCheck,
|
||||
onSuccess: async (data) => {
|
||||
toast.success(`Проверено IP: ${data.checked}`)
|
||||
await queryClient.invalidateQueries({ queryKey: healthStatusKeys.all })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось запустить проверку',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
function openCreateSheet() {
|
||||
setSheetMode('create')
|
||||
setEditTarget(null)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openEditSheet(row: SubdomainTableRow) {
|
||||
setSheetMode('edit')
|
||||
setEditTarget(row)
|
||||
setSheetOpen(true)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const filterFields = useSubdomainFilterFields()
|
||||
@@ -146,13 +100,7 @@ function DomainOverviewPage() {
|
||||
return String(row.serviceLinks[0].serviceId)
|
||||
}
|
||||
|
||||
async function handleSheetSubmit(values: SubdomainEditValues) {
|
||||
if (sheetMode === 'create') {
|
||||
await createSubdomainMutation.mutateAsync(values.name)
|
||||
setSheetOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
async function handleEditSubmit(values: SubdomainEditValues) {
|
||||
if (!editTarget) return
|
||||
|
||||
const nameChanged = values.name !== editTarget.subdomain.name
|
||||
@@ -185,207 +133,69 @@ function DomainOverviewPage() {
|
||||
})
|
||||
}
|
||||
|
||||
setSheetOpen(false)
|
||||
setEditOpen(false)
|
||||
}
|
||||
|
||||
const isSheetSaving =
|
||||
createSubdomainMutation.isPending ||
|
||||
updateSubdomainMutation.isPending ||
|
||||
linkServiceMutation.isPending
|
||||
updateSubdomainMutation.isPending || linkServiceMutation.isPending
|
||||
|
||||
const metricCards = useMemo(() => {
|
||||
if (!domain) return []
|
||||
return [
|
||||
{
|
||||
id: 'status',
|
||||
icon: <GlobeIcon aria-hidden="true" />,
|
||||
label: 'Статус зоны',
|
||||
description: domain.status,
|
||||
footer: <StatusBadge status={domain.status} />,
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
icon: <FolderTreeIcon aria-hidden="true" />,
|
||||
label: 'Группа',
|
||||
description: domain.group_id ? 'Домен в группе' : 'Без группы',
|
||||
footer: domain.group_id ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть группу
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
icon: <Link2Icon aria-hidden="true" />,
|
||||
label: 'Привязки',
|
||||
description: 'Сервисы в зоне',
|
||||
footer: (
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
{bindings.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
icon: <ServerIcon aria-hidden="true" />,
|
||||
label: 'Синхронизация',
|
||||
description: 'Последняя синхронизация с Cloudflare',
|
||||
footer: (
|
||||
<span className="text-foreground text-sm font-medium tabular-nums">
|
||||
{formatDate(domain.last_synced_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [bindings.length, domain])
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать
|
||||
</LoadingButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => runCheckMutation.mutate()}
|
||||
isLoading={runCheckMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
>
|
||||
Проверить сейчас
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
const activeTab: DomainSectionTab = tab ?? 'overview'
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<DomainPageSkeleton />}
|
||||
>
|
||||
{domain && (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={domain.zone_name}
|
||||
description="Карточка домена"
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'availability', label: 'Доступность' },
|
||||
{
|
||||
id: 'subdomains',
|
||||
label: 'Поддомены',
|
||||
count: subdomainRows.length,
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
label: 'Привязки',
|
||||
count: bindings.length,
|
||||
},
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<DetailPanel.Metrics cards={metricCards} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||
<DomainAvailabilityPanel domainId={id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subdomains" className="flex flex-col gap-4">
|
||||
<ResourcePage
|
||||
title="Поддомены"
|
||||
description={`Записи в зоне ${domain.zone_name}`}
|
||||
hideHeader
|
||||
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={subdomainTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() =>
|
||||
setFilters(createDefaultSubdomainFilters())
|
||||
}
|
||||
getFilterFieldValue={subdomainFilterFieldValue}
|
||||
columns={columns}
|
||||
data={subdomainRows}
|
||||
getRowId={(row) => String(row.subdomain.id)}
|
||||
toolbarExtra={
|
||||
<Button type="button" onClick={openCreateSheet}>
|
||||
<DomainDetailChrome domainId={domainId} activeTab={activeTab}>
|
||||
{({ openCreateSubdomain }) => (
|
||||
<>
|
||||
{activeTab === 'availability' ? (
|
||||
<DomainAvailabilityPanel domainId={id} />
|
||||
) : null}
|
||||
{activeTab === 'subdomains' && domain ? (
|
||||
<ResourcePage
|
||||
title="Поддомены"
|
||||
description={`Записи в зоне ${domain.zone_name}`}
|
||||
hideHeader
|
||||
tabs={SUBDOMAIN_TABS.map((item) => ({ ...item }))}
|
||||
tabFilter={subdomainTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultSubdomainFilters())}
|
||||
getFilterFieldValue={subdomainFilterFieldValue}
|
||||
columns={columns}
|
||||
data={subdomainRows}
|
||||
getRowId={(row) => String(row.subdomain.id)}
|
||||
emptyState={{
|
||||
title: 'Поддомены не созданы',
|
||||
description: 'Добавьте поддомен для привязки сервисов',
|
||||
action: (
|
||||
<Button type="button" variant="outline" onClick={openCreateSubdomain}>
|
||||
Добавить поддомен
|
||||
</Button>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'Поддомены не созданы',
|
||||
description: 'Добавьте поддомен для привязки сервисов',
|
||||
action: (
|
||||
<Button type="button" onClick={openCreateSheet}>
|
||||
Добавить поддомен
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
||||
<DomainBindingsPanel bindings={bindings} />
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
|
||||
<SubdomainEditSheet
|
||||
mode={sheetMode}
|
||||
subdomain={editTarget?.subdomain ?? null}
|
||||
zoneName={domain.zone_name}
|
||||
services={services}
|
||||
serviceGroupById={serviceGroupById}
|
||||
currentServiceId={
|
||||
editTarget ? resolveServiceId(editTarget) : 'none'
|
||||
}
|
||||
serviceLinks={editTarget?.serviceLinks ?? []}
|
||||
open={sheetOpen}
|
||||
isSaving={isSheetSaving}
|
||||
onOpenChange={setSheetOpen}
|
||||
onSubmit={handleSheetSubmit}
|
||||
/>
|
||||
</DetailPanel>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'bindings' ? (
|
||||
<DomainBindingsPanel bindings={bindings} />
|
||||
) : null}
|
||||
{domain ? (
|
||||
<SubdomainEditSheet
|
||||
mode="edit"
|
||||
subdomain={editTarget?.subdomain ?? null}
|
||||
zoneName={domain.zone_name}
|
||||
services={services}
|
||||
serviceGroupById={serviceGroupById}
|
||||
currentServiceId={editTarget ? resolveServiceId(editTarget) : 'none'}
|
||||
serviceLinks={editTarget?.serviceLinks ?? []}
|
||||
open={editOpen}
|
||||
isSaving={isSheetSaving}
|
||||
onOpenChange={setEditOpen}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
</DomainDetailChrome>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { CircleAlertIcon, GlobeIcon, SearchIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput } from '@/lib/schemas'
|
||||
import { formatDate, sqliteUtcToIso } from '@/lib/format'
|
||||
import { type BulkUpdateDomainsInput, type DomainListItem } from '@/lib/schemas'
|
||||
import {
|
||||
bulkUpdateDomains,
|
||||
dnsKeys,
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
serviceBindingKeys,
|
||||
subdomainKeys,
|
||||
} from '@/queries'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
@@ -31,8 +34,8 @@ import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,7 +44,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -53,8 +55,10 @@ export const Route = createFileRoute('/_auth/domains/')({
|
||||
})
|
||||
|
||||
function DomainsPage() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [importGroupId, setImportGroupId] = useState('')
|
||||
const [groupOpen, setGroupOpen] = useState(false)
|
||||
const [groupValue, setGroupValue] = useState('none')
|
||||
const selectedIdsRef = useRef<string[]>([])
|
||||
const [syncOpen, setSyncOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
@@ -80,24 +84,7 @@ function DomainsPage() {
|
||||
|
||||
const filterFields = useDomainFilterFields(groupItems)
|
||||
|
||||
const form = useForm<CreateDomainInput>({
|
||||
resolver: zodResolver(createDomainSchema),
|
||||
defaultValues: { zone_name: '', group_id: '' },
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: { zone_name: string; group_id?: number }) =>
|
||||
api.post('/api/v1/domains', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
form.reset({ zone_name: '', group_id: importGroupId })
|
||||
setSheetOpen(false)
|
||||
toast.success('Домен импортирован')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
|
||||
},
|
||||
})
|
||||
const form = useForm({ defaultValues: { group_id: '' } })
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
|
||||
@@ -112,6 +99,22 @@ function DomainsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: dnsKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: subdomainKeys.all }),
|
||||
])
|
||||
setSyncOpen(false)
|
||||
toast.success('Синхронизация завершена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
|
||||
},
|
||||
})
|
||||
|
||||
const bulkMutation = useMutation({
|
||||
mutationFn: (body: BulkUpdateDomainsInput) => bulkUpdateDomains(body),
|
||||
onSuccess: (data) => {
|
||||
@@ -129,23 +132,30 @@ function DomainsPage() {
|
||||
enableSelection: true,
|
||||
})
|
||||
|
||||
const handleCreate = (values: CreateDomainInput) => {
|
||||
createMutation.mutate({
|
||||
zone_name: values.zone_name.trim(),
|
||||
group_id: importGroupId ? Number(importGroupId) : undefined,
|
||||
})
|
||||
}
|
||||
const freshness = domainFreshnessLine(domains)
|
||||
|
||||
const handleImportGroupChange = (value: string | null) => {
|
||||
const next = value === 'none' || !value ? '' : value
|
||||
setImportGroupId(next)
|
||||
form.setValue('group_id', next)
|
||||
function openGroupSheet() {
|
||||
if (selectedIdsRef.current.length === 0) {
|
||||
toast.message('Выберите домены в таблице')
|
||||
return
|
||||
}
|
||||
setGroupOpen(true)
|
||||
}
|
||||
|
||||
const primaryAction = (
|
||||
<Button type="button" onClick={() => setSheetOpen(true)}>
|
||||
Импортировать домен
|
||||
</Button>
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={openGroupSheet}>
|
||||
Сменить группу
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
onClick={() => setSyncOpen(true)}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать с Cloudflare
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -155,6 +165,14 @@ function DomainsPage() {
|
||||
description="Импортированные зоны Cloudflare"
|
||||
actions={primaryAction}
|
||||
/>
|
||||
{freshness ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Badge variant="success-light" size="sm">
|
||||
Cloudflare
|
||||
</Badge>
|
||||
<span className="tabular-nums">{freshness}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<DomainKpiStats
|
||||
domains={domains}
|
||||
activeTab={activeTab}
|
||||
@@ -181,82 +199,104 @@ function DomainsPage() {
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
enableRowSelection
|
||||
selectionToolbar={({ selectedIds, clearSelection }) => (
|
||||
<DomainsBulkToolbar
|
||||
count={selectedIds.length}
|
||||
isPending={bulkMutation.isPending}
|
||||
groupItems={groupItems}
|
||||
onAssignGroup={(groupId) => {
|
||||
bulkMutation.mutate(
|
||||
{ ids: selectedIds.map(Number), group_id: groupId },
|
||||
{ onSuccess: () => clearSelection() },
|
||||
)
|
||||
}}
|
||||
onSetEnvironment={(environment) => {
|
||||
bulkMutation.mutate(
|
||||
{ ids: selectedIds.map(Number), environment },
|
||||
{ onSuccess: () => clearSelection() },
|
||||
)
|
||||
}}
|
||||
onAddTag={(tag) => {
|
||||
bulkMutation.mutate(
|
||||
{ ids: selectedIds.map(Number), tags_add: [tag] },
|
||||
{ onSuccess: () => clearSelection() },
|
||||
)
|
||||
}}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
)}
|
||||
emptyState={{
|
||||
title: 'Домены не импортированы',
|
||||
description: 'Импортируйте зону из аккаунта Cloudflare',
|
||||
errorState={{
|
||||
icon: CircleAlertIcon,
|
||||
title: 'Ошибка Cloudflare API',
|
||||
description: cloudflareErrorDescription(error),
|
||||
action: (
|
||||
<Button type="button" onClick={() => setSheetOpen(true)}>
|
||||
Импортировать домен
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings/integrations" />}
|
||||
>
|
||||
Открыть интеграции
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
filteredEmptyState={{
|
||||
icon: SearchIcon,
|
||||
title: 'Нет совпадений',
|
||||
description: 'Измените поиск или сбросьте фильтры.',
|
||||
resetLabel: 'Сбросить фильтры',
|
||||
}}
|
||||
onFilteredEmptyReset={() => setActiveTab('all')}
|
||||
enableRowSelection
|
||||
selectionToolbar={({ selectedIds, clearSelection }) => {
|
||||
selectedIdsRef.current = selectedIds
|
||||
return (
|
||||
<DomainsBulkToolbar
|
||||
count={selectedIds.length}
|
||||
isPending={bulkMutation.isPending}
|
||||
groupItems={groupItems}
|
||||
onAssignGroup={(groupId) => {
|
||||
bulkMutation.mutate(
|
||||
{ ids: selectedIds.map(Number), group_id: groupId },
|
||||
{ onSuccess: () => clearSelection() },
|
||||
)
|
||||
}}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
emptyState={{
|
||||
icon: GlobeIcon,
|
||||
title: 'Нет доменов',
|
||||
description:
|
||||
'Подключите токен Cloudflare в настройках и запустите синхронизацию.',
|
||||
action: (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSyncOpen(true)}
|
||||
>
|
||||
Синхронизировать
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title="Импорт домена"
|
||||
description="Добавить зону из аккаунта Cloudflare в менеджер"
|
||||
open={groupOpen}
|
||||
onOpenChange={setGroupOpen}
|
||||
title="Сменить группу"
|
||||
description="Группа применится к выбранным доменам"
|
||||
form={form}
|
||||
onSubmit={handleCreate}
|
||||
onSubmit={() => {
|
||||
const ids = selectedIdsRef.current.map(Number)
|
||||
if (ids.length === 0) return
|
||||
bulkMutation.mutate(
|
||||
{
|
||||
ids,
|
||||
group_id: groupValue === 'none' ? null : Number(groupValue),
|
||||
},
|
||||
{ onSuccess: () => setGroupOpen(false) },
|
||||
)
|
||||
}}
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={createMutation.isPending}
|
||||
loadingLabel="Импорт…"
|
||||
isLoading={bulkMutation.isPending}
|
||||
loadingLabel="Применение…"
|
||||
>
|
||||
Импортировать
|
||||
Применить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple
|
||||
label="Имя зоны"
|
||||
htmlFor="zone_name"
|
||||
error={form.formState.errors.zone_name}
|
||||
>
|
||||
<Input
|
||||
id="zone_name"
|
||||
placeholder="example.com"
|
||||
{...form.register('zone_name')}
|
||||
aria-invalid={!!form.formState.errors.zone_name}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Группа" htmlFor="import_group_id">
|
||||
<FormFieldSimple label="Группа" htmlFor="change_group_id" hint="Домены без группы можно оставить без изменений">
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={importGroupId || 'none'}
|
||||
onValueChange={handleImportGroupChange}
|
||||
value={groupValue}
|
||||
onValueChange={(value) => setGroupValue(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="import_group_id" className="w-full">
|
||||
<SelectTrigger id="change_group_id" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -271,6 +311,17 @@ function DomainsPage() {
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
|
||||
<ConfirmDialog
|
||||
open={syncOpen}
|
||||
onOpenChange={setSyncOpen}
|
||||
title="Синхронизировать с Cloudflare?"
|
||||
description="Будут запрошены зоны и записи DNS. Локальные привязки сервисов не изменятся."
|
||||
confirmLabel="Синхронизировать"
|
||||
confirmVariant="default"
|
||||
onConfirm={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
@@ -290,3 +341,29 @@ function DomainsPage() {
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function domainFreshnessLine(
|
||||
domains: DomainListItem[] | undefined,
|
||||
): string | null {
|
||||
if (!domains) return null
|
||||
const zoneCount = domains.length
|
||||
let latestMs = Number.NEGATIVE_INFINITY
|
||||
let latest: string | null = null
|
||||
for (const domain of domains) {
|
||||
if (!domain.last_synced_at) continue
|
||||
const ms = new Date(
|
||||
sqliteUtcToIso(domain.last_synced_at) ?? domain.last_synced_at,
|
||||
).getTime()
|
||||
if (Number.isNaN(ms) || ms <= latestMs) continue
|
||||
latestMs = ms
|
||||
latest = domain.last_synced_at
|
||||
}
|
||||
if (!latest) return `Синхронизация ещё не выполнялась · зон ${zoneCount}`
|
||||
return `Синхронизация ${formatDate(latest)} · зон ${zoneCount}`
|
||||
}
|
||||
|
||||
function cloudflareErrorDescription(error: Error | null): string {
|
||||
const hint = 'Проверьте токен в «Настройки → Интеграции».'
|
||||
if (!error?.message) return hint
|
||||
return `${error.message} ${hint}`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { FolderTreeIcon } from 'lucide-react'
|
||||
@@ -57,9 +57,17 @@ export const Route = createFileRoute('/_auth/groups')({
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
|
||||
]),
|
||||
component: GroupsPage,
|
||||
component: GroupsRoute,
|
||||
})
|
||||
|
||||
function GroupsRoute() {
|
||||
const isDetail = useRouterState({
|
||||
select: (state) => /^\/groups\/[^/]+/.test(state.location.pathname),
|
||||
})
|
||||
if (isDetail) return <Outlet />
|
||||
return <GroupsPage />
|
||||
}
|
||||
|
||||
function GroupsPage() {
|
||||
const { view: viewParam } = Route.useSearch()
|
||||
const view = viewParam === 'board' ? 'board' : 'catalog'
|
||||
@@ -203,15 +211,7 @@ function GroupsPage() {
|
||||
</Button>
|
||||
)
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
{createButton}
|
||||
<CatalogBoardToggle
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
const headerActions = createButton
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -324,8 +324,8 @@ function GroupsPage() {
|
||||
data={catalogRows}
|
||||
getRowId={(row) => String(row.id)}
|
||||
emptyState={{
|
||||
title: 'Группы не найдены',
|
||||
description: 'Создайте группу для организации доменов.',
|
||||
title: 'Нет групп',
|
||||
description: 'Создайте первую группу и назначьте в неё домены.',
|
||||
action: createButton,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FolderTreeIcon, GlobeIcon, HashIcon } from 'lucide-react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { GlobeIcon, HashIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
bulkUpdateDomains,
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
groupDetailQueryOptions,
|
||||
groupKeys,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { CreateGroupInput } from '@/lib/schemas'
|
||||
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
|
||||
import { DetailPanel, KpiStatGrid, ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
createDefaultGroupDomainFilters,
|
||||
groupDomainFilterFieldValue,
|
||||
@@ -52,55 +61,51 @@ function GroupDetailPage() {
|
||||
|
||||
const filterFields = useGroupDomainFilterFields()
|
||||
const columns = useGroupDomainColumns()
|
||||
const queryClient = useQueryClient()
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [picked, setPicked] = useState<number[]>([])
|
||||
const addForm = useForm({ defaultValues: { ids: '' } })
|
||||
const allDomains = useQuery({ ...domainsListQueryOptions(), enabled: addOpen })
|
||||
const ungrouped = (allDomains.data ?? []).filter((domain) => domain.group_id == null)
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: CreateGroupInput) => api.patch(`/api/v1/groups/${id}`, body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||
setEditOpen(false)
|
||||
toast.success('Группа сохранена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (ids: number[]) => bulkUpdateDomains({ ids, group_id: id }),
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
void queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||
setPicked([])
|
||||
setAddOpen(false)
|
||||
toast.success(`Добавлено: ${data.updated}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось добавить домены')
|
||||
},
|
||||
})
|
||||
|
||||
const headerActions = (
|
||||
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
||||
К группам
|
||||
</Button>
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => setEditOpen(true)} disabled={!group}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setAddOpen(true)} disabled={!group}>
|
||||
Добавить домены
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
const metricCards = useMemo(() => {
|
||||
if (!group) return []
|
||||
return [
|
||||
{
|
||||
id: 'domains',
|
||||
icon: <GlobeIcon aria-hidden="true" />,
|
||||
label: 'Домены',
|
||||
description: 'Зоны в этой группе',
|
||||
footer: (
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
{group.domain_count}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
icon: <HashIcon aria-hidden="true" />,
|
||||
label: 'Slug',
|
||||
description: 'Идентификатор группы',
|
||||
footer: (
|
||||
<span className="text-foreground font-mono text-sm">{group.slug}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
icon: <FolderTreeIcon aria-hidden="true" />,
|
||||
label: 'Группа',
|
||||
description: group.name,
|
||||
footer: (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" search={{ view: 'board' }} />}
|
||||
>
|
||||
Открыть канбан
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [group])
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
@@ -108,14 +113,31 @@ function GroupDetailPage() {
|
||||
title={group?.name ?? 'Группа'}
|
||||
description={
|
||||
group
|
||||
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
|
||||
? `Группа доменов · slug ${group.slug} · ${group.domain_count} домена`
|
||||
: 'Домены в группе'
|
||||
}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
{!isLoading && group ? (
|
||||
<DetailPanel.Metrics cards={metricCards} />
|
||||
<KpiStatGrid
|
||||
cards={[
|
||||
{
|
||||
id: 'domains',
|
||||
icon: <GlobeIcon aria-hidden />,
|
||||
label: 'Домены',
|
||||
value: group.domain_count,
|
||||
hint: 'в группе',
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
icon: <HashIcon aria-hidden />,
|
||||
label: 'Slug',
|
||||
value: group.slug,
|
||||
hint: 'для API',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DetailPanel.Section>
|
||||
@@ -140,21 +162,67 @@ function GroupDetailPage() {
|
||||
}}
|
||||
emptyState={{
|
||||
title: 'В группе нет доменов',
|
||||
description:
|
||||
'Перетащите домены на канбане групп или назначьте группу при импорте',
|
||||
description: 'Добавьте домены, которые ещё не назначены в группу.',
|
||||
action: (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
Открыть канбан групп
|
||||
<Button type="button" variant="outline" onClick={() => setAddOpen(true)}>
|
||||
Добавить домены
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
<DomainGroupEditSheet
|
||||
mode="edit"
|
||||
group={group ?? null}
|
||||
open={editOpen}
|
||||
isSaving={updateMutation.isPending}
|
||||
onOpenChange={setEditOpen}
|
||||
onSave={(_groupId, body) => updateMutation.mutate(body)}
|
||||
/>
|
||||
|
||||
<FormSheet
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Добавить домены"
|
||||
description="Только домены без группы"
|
||||
form={addForm}
|
||||
onSubmit={() => {
|
||||
if (picked.length === 0) return
|
||||
addMutation.mutate(picked)
|
||||
}}
|
||||
footer={
|
||||
<Button type="submit" disabled={picked.length === 0 || addMutation.isPending}>
|
||||
Добавить {picked.length || ''}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{ungrouped.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет доменов без группы</p>
|
||||
) : (
|
||||
ungrouped.map((domain) => {
|
||||
const on = picked.includes(domain.id)
|
||||
return (
|
||||
<Button
|
||||
key={domain.id}
|
||||
type="button"
|
||||
variant={on ? 'secondary' : 'outline'}
|
||||
className="justify-start"
|
||||
onClick={() =>
|
||||
setPicked((current) =>
|
||||
on ? current.filter((item) => item !== domain.id) : [...current, domain.id],
|
||||
)
|
||||
}
|
||||
>
|
||||
{domain.zone_name}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
+109
-220
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ActivityIcon,
|
||||
AlertTriangleIcon,
|
||||
@@ -13,43 +14,35 @@ import {
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
certificatesQueryOptions,
|
||||
certSummaryQueryOptions,
|
||||
dnsKeys,
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
opsSummaryQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
subdomainKeys,
|
||||
} from '@/queries'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
CertStatusChart,
|
||||
GroupDomainsChart,
|
||||
NodeHealthChart,
|
||||
OpsDashboard,
|
||||
QuickActionGrid,
|
||||
AttentionQueue,
|
||||
ExpiringCertGrid,
|
||||
HealthProblemGrid,
|
||||
UngroupedDomainGrid,
|
||||
type KpiStatCard,
|
||||
type QuickActionItem,
|
||||
} from '@/components/reui-kit'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
Promise.allSettled([
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||
queryClient.ensureQueryData(certificatesQueryOptions()),
|
||||
queryClient.ensureQueryData(opsSummaryQueryOptions()),
|
||||
@@ -57,13 +50,48 @@ export const Route = createFileRoute('/_auth/')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : undefined
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
|
||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
data: domains,
|
||||
isLoading: domainsLoading,
|
||||
isError: domainsFailed,
|
||||
error: domainsQueryError,
|
||||
refetch: refetchDomains,
|
||||
} = useQuery(domainsListQueryOptions())
|
||||
const {
|
||||
data: certs,
|
||||
isError: certsFailed,
|
||||
error: certsQueryError,
|
||||
refetch: refetchCerts,
|
||||
} = useQuery(certificatesQueryOptions())
|
||||
const {
|
||||
data: serviceData,
|
||||
isError: servicesFailed,
|
||||
error: servicesQueryError,
|
||||
refetch: refetchServices,
|
||||
} = useQuery(serviceGroupsQueryOptions())
|
||||
const { data: ops } = useQuery(opsSummaryQueryOptions())
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
|
||||
onSuccess: async () => {
|
||||
toast.success('Синхронизация завершена')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: subdomainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: dnsKeys.all }),
|
||||
])
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Не удалось синхронизировать домены',
|
||||
)
|
||||
},
|
||||
})
|
||||
const { data: appSettings } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
|
||||
@@ -95,7 +123,8 @@ function DashboardPage() {
|
||||
to: '/services',
|
||||
search: { domainId: undefined },
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
iconClassName: 'text-warning',
|
||||
badgeVariant: 'warning-light',
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
@@ -119,23 +148,7 @@ function DashboardPage() {
|
||||
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
||||
(serviceData?.ungrouped.length ?? 0)
|
||||
|
||||
const isLoading = domainsLoading || summaryLoading
|
||||
|
||||
const statusChartData = useMemo(
|
||||
() => (summary ?? []).map(([status, count]) => ({ status, count })),
|
||||
[summary],
|
||||
)
|
||||
|
||||
const groupChartData = useMemo(() => {
|
||||
const list = groups ?? []
|
||||
return list
|
||||
.map((g) => ({
|
||||
name: g.name,
|
||||
count: domains?.filter((d) => d.group_id === g.id).length ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6)
|
||||
}, [groups, domains])
|
||||
const isLoading = domainsLoading
|
||||
|
||||
const [nowMs] = useState(() => Date.now())
|
||||
|
||||
@@ -147,88 +160,50 @@ function DashboardPage() {
|
||||
const expiringCerts = useMemo(() => {
|
||||
const warnMs = 14 * 24 * 60 * 60 * 1000
|
||||
return (certs ?? [])
|
||||
.filter((c) => c.expires_at)
|
||||
.map((c) => ({ cert: c, ts: new Date(c.expires_at as string).getTime() }))
|
||||
.filter((e) => !Number.isNaN(e.ts) && e.ts - nowMs <= warnMs)
|
||||
.filter((cert) => cert.expires_at)
|
||||
.map((cert) => ({
|
||||
cert,
|
||||
ts: new Date(cert.expires_at as string).getTime(),
|
||||
}))
|
||||
.filter((entry) => !Number.isNaN(entry.ts) && entry.ts - nowMs <= warnMs)
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
.slice(0, 8)
|
||||
.map((entry) => entry.cert)
|
||||
}, [certs, nowMs])
|
||||
|
||||
const ungroupedDomains = useMemo(
|
||||
() => (domains ?? []).filter((d) => d.group_id == null).slice(0, 5),
|
||||
() => (domains ?? []).filter((domain) => domain.group_id == null),
|
||||
[domains],
|
||||
)
|
||||
|
||||
const attentionServices = useMemo(() => {
|
||||
const list: Array<{
|
||||
id: number
|
||||
name: string
|
||||
health_status: string
|
||||
health_latency_ms: number | null
|
||||
}> = []
|
||||
const list: ServiceView[] = []
|
||||
const pushIfUnhealthy = (service: ServiceView) => {
|
||||
if (service.health_status === 'down' || service.health_status === 'degraded') {
|
||||
list.push(service)
|
||||
}
|
||||
}
|
||||
for (const group of serviceData?.groups ?? []) {
|
||||
for (const service of group.services) {
|
||||
if (
|
||||
service.health_status === 'down' ||
|
||||
service.health_status === 'degraded'
|
||||
) {
|
||||
list.push({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
health_status: service.health_status,
|
||||
health_latency_ms: service.health_latency_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const service of group.services) pushIfUnhealthy(service)
|
||||
}
|
||||
for (const service of serviceData?.ungrouped ?? []) {
|
||||
if (
|
||||
service.health_status === 'down' ||
|
||||
service.health_status === 'degraded'
|
||||
) {
|
||||
list.push({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
health_status: service.health_status,
|
||||
health_latency_ms: service.health_latency_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
return list.slice(0, 8)
|
||||
for (const service of serviceData?.ungrouped ?? []) pushIfUnhealthy(service)
|
||||
return list
|
||||
}, [serviceData])
|
||||
|
||||
const attentionCount = attentionServices.length
|
||||
|
||||
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
debugAgentLog(
|
||||
'index.tsx:dashboard-data',
|
||||
'dashboard chart inputs',
|
||||
{
|
||||
buildStamp: DEBUG_BUILD_STAMP,
|
||||
summaryRaw: summary ?? null,
|
||||
statusChartLen: statusChartData.length,
|
||||
statusChartData,
|
||||
groupChartLen: groupChartData.length,
|
||||
groupChartData,
|
||||
domainsLen: domains?.length ?? 0,
|
||||
groupsLen: groups?.length ?? 0,
|
||||
},
|
||||
'C',
|
||||
)
|
||||
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
||||
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'domains',
|
||||
label: 'Домены',
|
||||
value: domains?.length ?? 0,
|
||||
value: domainsFailed && domains == null ? '—' : (domains?.length ?? 0),
|
||||
hint:
|
||||
ungroupedCount > 0
|
||||
? `${ungroupedCount} без группы`
|
||||
: 'Все в группах',
|
||||
domainsFailed && domains == null
|
||||
? undefined
|
||||
: ungroupedCount > 0
|
||||
? `${ungroupedCount} без группы`
|
||||
: 'Все в группах',
|
||||
icon: <GlobeIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
variant: ungroupedCount > 0 ? 'warning' : 'default',
|
||||
@@ -258,30 +233,15 @@ function DashboardPage() {
|
||||
iconClassName: 'text-info',
|
||||
to: '/services',
|
||||
},
|
||||
{
|
||||
id: 'healthy',
|
||||
label: 'Healthy',
|
||||
value: ops?.healthy ?? 0,
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'unhealthy',
|
||||
label: 'Unhealthy',
|
||||
value: ops?.unhealthy ?? 0,
|
||||
hint: `активных failover: ${ops?.active_failovers ?? 0}`,
|
||||
variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default',
|
||||
icon: <AlertTriangleIcon aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
},
|
||||
{
|
||||
id: 'failovers',
|
||||
label: 'Failover',
|
||||
value: ops?.active_failovers ?? 0,
|
||||
hint: 'активные переключения',
|
||||
variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -289,6 +249,16 @@ function DashboardPage() {
|
||||
<PageHeader
|
||||
title="Панель управления"
|
||||
description="Обзор доменов, групп, сервисов и сертификатов Cloudflare"
|
||||
actions={
|
||||
<LoadingButton
|
||||
type="button"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
Синхронизировать
|
||||
</LoadingButton>
|
||||
}
|
||||
/>
|
||||
<OpsDashboard
|
||||
isLoading={isLoading}
|
||||
@@ -301,12 +271,7 @@ function DashboardPage() {
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
charts={
|
||||
<>
|
||||
<CertStatusChart data={statusChartData} />
|
||||
<GroupDomainsChart data={groupChartData} />
|
||||
</>
|
||||
}
|
||||
charts={<NodeHealthChart />}
|
||||
queue={
|
||||
<AttentionQueue
|
||||
columns={[
|
||||
@@ -319,6 +284,11 @@ function DashboardPage() {
|
||||
countVariant: 'destructive-light',
|
||||
emptyTitle: 'Нет проблем',
|
||||
emptyDescription: 'Нет сервисов со статусом Down или Slow',
|
||||
isError: servicesFailed && serviceData == null,
|
||||
errorMessage: errorMessage(servicesQueryError),
|
||||
onRetry: () => {
|
||||
void refetchServices()
|
||||
},
|
||||
emptyAction: (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -329,45 +299,7 @@ function DashboardPage() {
|
||||
К сервисам
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<ItemGroup className="gap-2">
|
||||
{attentionServices.map((service) => (
|
||||
<Item key={service.id} variant="outline" size="sm">
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
{service.name}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<HealthCheckBadge
|
||||
status={
|
||||
(service.health_status as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'degraded'
|
||||
| 'unknown') ?? 'unknown'
|
||||
}
|
||||
latencyMs={service.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ serviceId: service.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
),
|
||||
children: <HealthProblemGrid services={attentionServices} />,
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
@@ -378,6 +310,11 @@ function DashboardPage() {
|
||||
countVariant: 'warning-light',
|
||||
emptyTitle: 'Нет истечений',
|
||||
emptyDescription: 'В пределах 14 дней истечений нет',
|
||||
isError: certsFailed && certs == null,
|
||||
errorMessage: errorMessage(certsQueryError),
|
||||
onRetry: () => {
|
||||
void refetchCerts()
|
||||
},
|
||||
emptyAction: (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -388,33 +325,7 @@ function DashboardPage() {
|
||||
К сертификатам
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<ItemGroup className="gap-2">
|
||||
{expiringCerts.map(({ cert, ts }) => (
|
||||
<Item key={cert.id} variant="outline" size="sm">
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
{cert.hostname}
|
||||
</ItemTitle>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatRelative(new Date(ts).toISOString())}
|
||||
</span>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<StatusBadge status={cert.status} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
),
|
||||
children: <ExpiringCertGrid certs={expiringCerts} />,
|
||||
},
|
||||
{
|
||||
id: 'ungrouped',
|
||||
@@ -425,6 +336,11 @@ function DashboardPage() {
|
||||
countVariant: 'warning-light',
|
||||
emptyTitle: 'Всё сгруппировано',
|
||||
emptyDescription: 'Все домены в группах',
|
||||
isError: domainsFailed && domains == null,
|
||||
errorMessage: errorMessage(domainsQueryError),
|
||||
onRetry: () => {
|
||||
void refetchDomains()
|
||||
},
|
||||
emptyAction: (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -435,34 +351,7 @@ function DashboardPage() {
|
||||
К доменам
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<ItemGroup className="gap-2">
|
||||
{ungroupedDomains.map((domain) => (
|
||||
<Item key={domain.id} variant="outline" size="sm">
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
{domain.zone_name}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
),
|
||||
children: <UngroupedDomainGrid domains={ungroupedDomains} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { ServiceHealthSection } from '@/components/services/service-health-section'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
component: ServiceHealthPage,
|
||||
})
|
||||
|
||||
function ServiceHealthPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
return <ServiceHealthSection serviceId={serviceId} />
|
||||
}
|
||||
@@ -1,454 +1,12 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ActivityIcon,
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import {
|
||||
ServiceDetailGrid,
|
||||
type ServiceFqdnRow,
|
||||
} from '@/components/services/service-detail-grid'
|
||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||
import {
|
||||
KpiStatGrid,
|
||||
ServiceFailoverPanel,
|
||||
ServiceHealthMonitor,
|
||||
} from '@/components/reui-kit'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { hasSharedPool } from '@/lib/failover-events'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
resolveServiceDisplayHealth,
|
||||
} from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import {
|
||||
createServiceNode,
|
||||
deleteServiceNode,
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceDetailKeys,
|
||||
serviceFailoverLogQueryOptions,
|
||||
serviceGroupKeys,
|
||||
serviceGroupsQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceKeys,
|
||||
serviceNodesQueryOptions,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { ServiceAssetSection } from '@/components/services/service-asset-section'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||
component: ServiceDetailPage,
|
||||
})
|
||||
|
||||
interface OverviewPayload {
|
||||
routing_strategy?: string
|
||||
active_addresses?: string[]
|
||||
nodes?: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
consecutive_failures: number
|
||||
last_failure_reason: string | null
|
||||
last_check_at?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
function ServiceDetailPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const failoverLogQuery = useQuery(serviceFailoverLogQueryOptions(id))
|
||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||
const groupsQuery = useQuery(serviceGroupsQueryOptions())
|
||||
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||
|
||||
const service = viewQuery.data
|
||||
const overview = overviewQuery.data as OverviewPayload | undefined
|
||||
const logItems = useMemo(
|
||||
() => logQuery.data?.items ?? [],
|
||||
[logQuery.data?.items],
|
||||
)
|
||||
const failoverHistory = useMemo(
|
||||
() => failoverLogQuery.data?.items ?? [],
|
||||
[failoverLogQuery.data?.items],
|
||||
)
|
||||
const failoverBindings = useMemo(
|
||||
() =>
|
||||
(service?.domains ?? []).map((domain) => ({
|
||||
fqdn: domain.fqdn,
|
||||
configured: domain.target_ips,
|
||||
active: domain.active_ips ?? [],
|
||||
})),
|
||||
[service?.domains],
|
||||
)
|
||||
const uniqueEnabledIps = useMemo(
|
||||
() =>
|
||||
(service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
|
||||
[service?.ips, service?.ip_enabled],
|
||||
)
|
||||
const displayHealth = useMemo(
|
||||
() =>
|
||||
resolveServiceDisplayHealth(
|
||||
service?.health_status,
|
||||
service?.ip_health ?? [],
|
||||
logItems,
|
||||
),
|
||||
[service?.health_status, service?.ip_health, logItems],
|
||||
)
|
||||
const showPoolPanel =
|
||||
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
|
||||
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||
const [changeIp, setChangeIp] = useState<{
|
||||
bindingId: number
|
||||
ip?: string
|
||||
} | null>(null)
|
||||
const [changeDomain, setChangeDomain] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const nodeForm = useForm<{ address: string; port: string }>({
|
||||
defaultValues: { address: '', port: '' },
|
||||
})
|
||||
|
||||
const groups = groupsQuery.data
|
||||
? [...groupsQuery.data.groups]
|
||||
: []
|
||||
|
||||
async function invalidateService() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
|
||||
])
|
||||
}
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||
onSuccess: async () => {
|
||||
await invalidateService()
|
||||
setEditOpen(false)
|
||||
toast.success('Сервис сохранён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||
},
|
||||
onSettled: () => setSaving(false),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/api/v1/services/${id}`),
|
||||
onSuccess: async () => {
|
||||
await invalidateService()
|
||||
toast.success('Сервис удалён')
|
||||
await navigate({ to: '/services' })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||
},
|
||||
})
|
||||
|
||||
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 invalidateService()
|
||||
toast.success(
|
||||
enabled
|
||||
? 'IP включён и добавлен в DNS-привязки'
|
||||
: 'IP выключен и снят с DNS-привязок',
|
||||
)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
|
||||
},
|
||||
onSettled: () => setTogglingIp(null),
|
||||
})
|
||||
|
||||
const createNodeMut = useMutation({
|
||||
mutationFn: (values: { address: string; port: string }) =>
|
||||
createServiceNode(id, {
|
||||
address: values.address.trim(),
|
||||
port: values.port ? Number(values.port) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода добавлена, статус CHECKING')
|
||||
await invalidateService()
|
||||
setAddNodeOpen(false)
|
||||
nodeForm.reset()
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||
})
|
||||
|
||||
const deleteNodeMut = useMutation({
|
||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода удалена')
|
||||
await invalidateService()
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
|
||||
const isError = viewQuery.isError || overviewQuery.isError
|
||||
const error = viewQuery.error ?? overviewQuery.error
|
||||
|
||||
const enabledProviders = useMemo(
|
||||
() => enabledHealthProviders(service?.domains ?? []),
|
||||
[service],
|
||||
)
|
||||
const providerStatuses = useMemo(
|
||||
() => providerHealthStatuses(logItems, enabledProviders),
|
||||
[logItems, enabledProviders],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void viewQuery.refetch()
|
||||
void overviewQuery.refetch()
|
||||
}}
|
||||
>
|
||||
{!service ? (
|
||||
<EmptyState
|
||||
title="Сервис не найден"
|
||||
description="Вернитесь в каталог и выберите сервис."
|
||||
/>
|
||||
) : (
|
||||
<div className="@container flex w-full flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title={service.name}
|
||||
description="Domain → Service → Node → Health → Failover"
|
||||
actions={
|
||||
<>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<HealthCheckBadge status={displayHealth} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Изменить"
|
||||
onClick={() => setEditOpen(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PencilIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Изменить</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid
|
||||
cards={[
|
||||
{
|
||||
id: 'status',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Статус',
|
||||
value: displayHealth === 'up' ? 'OK' : displayHealth,
|
||||
variant:
|
||||
displayHealth === 'down'
|
||||
? 'destructive'
|
||||
: displayHealth === 'degraded'
|
||||
? 'warning'
|
||||
: 'default',
|
||||
iconClassName:
|
||||
displayHealth === 'down'
|
||||
? 'text-destructive'
|
||||
: displayHealth === 'degraded'
|
||||
? 'text-warning'
|
||||
: 'text-success',
|
||||
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
|
||||
},
|
||||
{
|
||||
id: 'fqdn',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'FQDN',
|
||||
value: String(service.domains.length),
|
||||
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
icon: <NetworkIcon />,
|
||||
label: 'IP',
|
||||
value: String(service.ips.length),
|
||||
hint: showPoolPanel
|
||||
? `${service.active_ips.length} в пуле`
|
||||
: uniqueEnabledIps.length <= 1
|
||||
? 'без балансировки'
|
||||
: service.ips.join(', ') || 'нет',
|
||||
},
|
||||
{
|
||||
id: 'pool',
|
||||
icon: <ServerIcon />,
|
||||
label: showPoolPanel ? 'Активный пул' : 'Адреса',
|
||||
value: String(
|
||||
(overview?.active_addresses ?? service.active_ips).length,
|
||||
),
|
||||
hint:
|
||||
(overview?.active_addresses ?? service.active_ips).join(', ') ||
|
||||
'нет',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className={
|
||||
showPoolPanel
|
||||
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
|
||||
: 'grid min-w-0 items-start gap-2'
|
||||
}
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
enabledProviders={enabledProviders}
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isLoading}
|
||||
/>
|
||||
{showPoolPanel ? (
|
||||
<ServiceFailoverPanel
|
||||
lbMode={service.lb_mode}
|
||||
ipHealth={service.ip_health}
|
||||
bindings={failoverBindings}
|
||||
history={failoverHistory}
|
||||
probes={logItems}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Пустой сервис"
|
||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||
stackedIcon
|
||||
/>
|
||||
) : (
|
||||
<ServiceDetailGrid
|
||||
service={service}
|
||||
nodes={nodes}
|
||||
probes={logItems}
|
||||
togglingIp={togglingIp}
|
||||
onToggleIp={(ip, enabled) => {
|
||||
setTogglingIp(ip)
|
||||
toggleIpMutation.mutate({ ip, enabled })
|
||||
}}
|
||||
onChangeIp={(row: ServiceFqdnRow) =>
|
||||
setChangeIp({
|
||||
bindingId: row.binding_id,
|
||||
ip: row.target_ips[0],
|
||||
})
|
||||
}
|
||||
onChangeDomain={() => setChangeDomain(true)}
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
|
||||
isLoading={nodesQuery.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
service={service}
|
||||
groups={groups}
|
||||
open={editOpen}
|
||||
knownDomains={domainsQuery.data ?? []}
|
||||
isSaving={saving}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onOpenChange={setEditOpen}
|
||||
onSave={(_serviceId, body) => {
|
||||
setSaving(true)
|
||||
updateMutation.mutate({ body })
|
||||
}}
|
||||
onDelete={() => deleteMutation.mutate()}
|
||||
/>
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.bindingId ?? null}
|
||||
serviceId={id}
|
||||
currentIp={changeIp?.ip}
|
||||
/>
|
||||
<ChangeDomainSheet
|
||||
open={changeDomain}
|
||||
onOpenChange={setChangeDomain}
|
||||
serviceId={id}
|
||||
fromDomainId={service.domains[0]?.domain_id ?? null}
|
||||
/>
|
||||
<FormSheet
|
||||
open={addNodeOpen}
|
||||
onOpenChange={setAddNodeOpen}
|
||||
title="Добавить ноду"
|
||||
description="IP станет CHECKING до порога успешных проверок."
|
||||
form={nodeForm}
|
||||
onSubmit={(values) => createNodeMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="IP" htmlFor="address">
|
||||
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
)
|
||||
return <ServiceAssetSection serviceId={serviceId} section="nodes" />
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { ServiceAssetSection } from '@/components/services/service-asset-section'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
component: ServiceNodesPage,
|
||||
})
|
||||
|
||||
function ServiceNodesPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
return <ServiceAssetSection serviceId={serviceId} section="nodes" />
|
||||
}
|
||||
@@ -1,12 +1,41 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { ActivityIcon, GlobeIcon, NetworkIcon, ServerIcon } from 'lucide-react'
|
||||
|
||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
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 { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import type { ServiceFqdnRow } from '@/components/services/service-detail-grid'
|
||||
import { ServiceDetailActionsProvider } from '@/components/services/service-detail-context'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { hasSharedPool } from '@/lib/failover-events'
|
||||
import { resolveServiceDisplayHealth } from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import {
|
||||
createServiceNode,
|
||||
domainsListQueryOptions,
|
||||
invalidateServiceDetail,
|
||||
serviceFailoverLogQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceNodesQueryOptions,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
@@ -23,10 +52,346 @@ export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||
component: ServiceLayout,
|
||||
})
|
||||
|
||||
const SECTION_TABS = [
|
||||
{ id: 'nodes', label: 'Ноды', to: '/services/$serviceId/nodes' },
|
||||
{ id: 'health', label: 'Health', to: '/services/$serviceId/health' },
|
||||
{ id: 'routing', label: 'Маршрутизация', to: '/services/$serviceId/routing' },
|
||||
{ id: 'subdomains', label: 'Поддомены', to: '/services/$serviceId/subdomains' },
|
||||
] as const
|
||||
|
||||
function activeSection(pathname: string, serviceId: string) {
|
||||
const path = pathname.replace(/\/$/, '')
|
||||
const base = `/services/${serviceId}`
|
||||
if (path === `${base}/health`) return 'health'
|
||||
if (path === `${base}/routing`) return 'routing'
|
||||
if (path === `${base}/subdomains`) return 'subdomains'
|
||||
return 'nodes'
|
||||
}
|
||||
|
||||
/**
|
||||
* Общая полоса вкладок карточки сервиса поверх существующих маршрутов.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Preview: https://reui.io/preview/base/form-7
|
||||
* Preview: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
function ServiceLayout() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname })
|
||||
const section = activeSection(pathname, serviceId)
|
||||
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||
const groupsQuery = useQuery(serviceGroupsQueryOptions())
|
||||
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||
|
||||
const service = viewQuery.data
|
||||
const logItems = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items])
|
||||
const nodeCount = Array.isArray(nodesQuery.data) ? nodesQuery.data.length : undefined
|
||||
|
||||
const displayHealth = useMemo(
|
||||
() =>
|
||||
resolveServiceDisplayHealth(
|
||||
service?.health_status,
|
||||
service?.ip_health ?? [],
|
||||
logItems,
|
||||
),
|
||||
[service?.health_status, service?.ip_health, logItems],
|
||||
)
|
||||
const failoverBindings = useMemo(
|
||||
() =>
|
||||
(service?.domains ?? []).map((domain) => ({
|
||||
fqdn: domain.fqdn,
|
||||
configured: domain.target_ips,
|
||||
active: domain.active_ips ?? [],
|
||||
})),
|
||||
[service?.domains],
|
||||
)
|
||||
const uniqueEnabledIps = useMemo(
|
||||
() => (service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
|
||||
[service?.ips, service?.ip_enabled],
|
||||
)
|
||||
const showPoolPanel =
|
||||
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const [changeIp, setChangeIp] = useState<{ bindingId: number; ip?: string } | null>(null)
|
||||
const [changeDomain, setChangeDomain] = useState(false)
|
||||
const nodeForm = useForm<{ address: string; port: string }>({
|
||||
defaultValues: { address: '', port: '' },
|
||||
})
|
||||
|
||||
const groups = groupsQuery.data ? [...groupsQuery.data.groups] : []
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||
onSuccess: async () => {
|
||||
await invalidateServiceDetail(queryClient, id)
|
||||
setEditOpen(false)
|
||||
toast.success('Сервис сохранён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||
},
|
||||
onSettled: () => setSaving(false),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/api/v1/services/${id}`),
|
||||
onSuccess: async () => {
|
||||
await invalidateServiceDetail(queryClient, id)
|
||||
toast.success('Сервис удалён')
|
||||
await navigate({ to: '/services' })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||
},
|
||||
})
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: () => api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled: false }),
|
||||
onSuccess: async () => {
|
||||
await invalidateServiceDetail(queryClient, id)
|
||||
toast.success('Сервис выключен')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось выключить сервис')
|
||||
},
|
||||
})
|
||||
|
||||
const createNodeMut = useMutation({
|
||||
mutationFn: (values: { address: string; port: string }) =>
|
||||
createServiceNode(id, {
|
||||
address: values.address.trim(),
|
||||
port: values.port ? Number(values.port) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода добавлена, статус CHECKING')
|
||||
await invalidateServiceDetail(queryClient, id)
|
||||
setAddNodeOpen(false)
|
||||
nodeForm.reset()
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось добавить ноду'),
|
||||
})
|
||||
|
||||
const counts: Record<(typeof SECTION_TABS)[number]['id'], number | undefined> = {
|
||||
nodes: nodeCount,
|
||||
health: undefined,
|
||||
routing: service?.ips.length,
|
||||
subdomains: service?.domains.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<Outlet />
|
||||
<QueryState
|
||||
isLoading={viewQuery.isLoading}
|
||||
isError={viewQuery.isError}
|
||||
error={viewQuery.error}
|
||||
onRetry={() => {
|
||||
void viewQuery.refetch()
|
||||
}}
|
||||
>
|
||||
{!service ? (
|
||||
<EmptyState
|
||||
title="Сервис не найден"
|
||||
description="Вернитесь в каталог и выберите сервис."
|
||||
/>
|
||||
) : (
|
||||
<ServiceDetailActionsProvider
|
||||
value={{
|
||||
openAddNode: () => setAddNodeOpen(true),
|
||||
openChangeIp: (row: ServiceFqdnRow) =>
|
||||
setChangeIp({ bindingId: row.binding_id, ip: row.target_ips[0] }),
|
||||
openChangeDomain: () => setChangeDomain(true),
|
||||
}}
|
||||
>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={service.name}
|
||||
description={`Domain → Service → Node → Health → Failover · группа «${groups.find((group) => group.id === service.service_group_id)?.name ?? 'Без группы'}»`}
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => setEditOpen(true)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setAddNodeOpen(true)}>
|
||||
Добавить ноду
|
||||
</Button>
|
||||
{service.enabled ? (
|
||||
<ConfirmDialog
|
||||
title="Выключить сервис?"
|
||||
description="Сервис будет выключен, связанные DNS-записи будут сняты."
|
||||
confirmLabel="Выключить"
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
Выключить
|
||||
</Button>
|
||||
}
|
||||
onConfirm={() => disableMutation.mutate()}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid
|
||||
cards={[
|
||||
{
|
||||
id: 'status',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Статус',
|
||||
value:
|
||||
displayHealth === 'up'
|
||||
? 'Up'
|
||||
: displayHealth === 'degraded'
|
||||
? 'Slow'
|
||||
: displayHealth === 'down'
|
||||
? 'Down'
|
||||
: displayHealth,
|
||||
variant:
|
||||
displayHealth === 'down'
|
||||
? 'destructive'
|
||||
: displayHealth === 'degraded'
|
||||
? 'warning'
|
||||
: 'default',
|
||||
iconClassName:
|
||||
displayHealth === 'down'
|
||||
? 'text-destructive'
|
||||
: displayHealth === 'degraded'
|
||||
? 'text-warning'
|
||||
: 'text-success',
|
||||
hint:
|
||||
service.health_latency_ms != null
|
||||
? `${service.health_latency_ms} мс`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'fqdn',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'FQDN',
|
||||
value: service.domains[0]?.fqdn ?? '—',
|
||||
hint: service.domains[0]?.fqdn ? 'основной поддомен' : 'Нет привязанных FQDN',
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
icon: <NetworkIcon />,
|
||||
label: 'IP',
|
||||
value: String(service.ips.length),
|
||||
hint: showPoolPanel
|
||||
? `${service.active_ips.length} в пуле`
|
||||
: uniqueEnabledIps.length <= 1
|
||||
? 'без балансировки'
|
||||
: service.ips.join(', ') || 'нет',
|
||||
},
|
||||
{
|
||||
id: 'pool',
|
||||
icon: <ServerIcon />,
|
||||
label: showPoolPanel ? 'Активный пул' : 'Адреса',
|
||||
value: String(service.active_ips.length),
|
||||
hint: service.active_ips.join(', ') || 'нет',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<nav aria-label="Разделы сервиса" className="flex gap-5 overflow-x-auto">
|
||||
{SECTION_TABS.map((tab) => {
|
||||
const isActive = section === tab.id
|
||||
const count = counts[tab.id]
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={tab.to}
|
||||
params={{ serviceId }}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground relative inline-flex h-auto shrink-0 items-center gap-2 px-0 pb-3 text-sm font-medium transition-colors',
|
||||
'after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground after:opacity-0',
|
||||
isActive && 'text-foreground after:opacity-100',
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{count !== undefined ? (
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Outlet />
|
||||
</DetailPanel>
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
service={service}
|
||||
groups={groups}
|
||||
open={editOpen}
|
||||
knownDomains={domainsQuery.data ?? []}
|
||||
isSaving={saving}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onOpenChange={setEditOpen}
|
||||
onSave={(_serviceId, body) => {
|
||||
setSaving(true)
|
||||
updateMutation.mutate({ body })
|
||||
}}
|
||||
onDelete={() => deleteMutation.mutate()}
|
||||
/>
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.bindingId ?? null}
|
||||
serviceId={id}
|
||||
currentIp={changeIp?.ip}
|
||||
/>
|
||||
<ChangeDomainSheet
|
||||
open={changeDomain}
|
||||
onOpenChange={setChangeDomain}
|
||||
serviceId={id}
|
||||
fromDomainId={service.domains[0]?.domain_id ?? null}
|
||||
/>
|
||||
<FormSheet
|
||||
open={addNodeOpen}
|
||||
onOpenChange={setAddNodeOpen}
|
||||
title="Добавить ноду"
|
||||
description="IP станет CHECKING до порога успешных проверок."
|
||||
form={nodeForm}
|
||||
onSubmit={(values) => createNodeMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="IP" htmlFor="node-address">
|
||||
<Input
|
||||
id="node-address"
|
||||
{...nodeForm.register('address')}
|
||||
placeholder="10.0.0.10"
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Порт" htmlFor="node-port" hint="Необязательно">
|
||||
<Input id="node-port" {...nodeForm.register('port')} placeholder="443" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
</ServiceDetailActionsProvider>
|
||||
)}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { ServiceRoutingSection } from '@/components/services/service-routing-section'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
component: ServiceRoutingPage,
|
||||
})
|
||||
|
||||
function ServiceRoutingPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
return <ServiceRoutingSection serviceId={serviceId} />
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { ServiceAssetSection } from '@/components/services/service-asset-section'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
component: ServiceSubdomainsPage,
|
||||
})
|
||||
|
||||
function ServiceSubdomainsPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ServiceAssetSection serviceId={serviceId} section="fqdn" />
|
||||
<ServiceAssetSection serviceId={serviceId} section="ssl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
serviceKeys,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { formatDate, sqliteUtcToIso } from '@/lib/format'
|
||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import type {
|
||||
CreateServiceGroupInput,
|
||||
CreateServiceWithConfigInput,
|
||||
@@ -23,17 +25,14 @@ import type {
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ServiceKpiStats } from '@/components/service-kpi-stats'
|
||||
import { IncidentAlert } from '@/components/incident-alert'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
|
||||
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
|
||||
import { type ServiceCatalogRow } from '@/components/columns/services-columns'
|
||||
import {
|
||||
ServicesAddMenu,
|
||||
ServicesGroupedCatalog,
|
||||
} from '@/components/services/services-grouped-catalog'
|
||||
import { ServicesGroupedCatalog } from '@/components/services/services-grouped-catalog'
|
||||
import { useServicesKanban } from '@/hooks/use-services-kanban'
|
||||
import { useServicesSelection } from '@/hooks/use-services-selection'
|
||||
import {
|
||||
@@ -223,6 +222,10 @@ function ServicesPage() {
|
||||
() => flattenServices(data, domainId),
|
||||
[data, domainId],
|
||||
)
|
||||
const downService = useMemo(
|
||||
() => pickHeaviestDown(flattenServices(data)),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (serviceId == null || !data || editingService) return
|
||||
@@ -248,15 +251,6 @@ function ServicesPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function setView(next: 'catalog' | 'board') {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
view: next === 'board' ? 'board' : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function invalidateAll() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||
@@ -561,17 +555,14 @@ function ServicesPage() {
|
||||
? 'Перетаскивание отключено при фильтре по домену'
|
||||
: 'Перетащите сервис между группами или измените порядок в списке'
|
||||
|
||||
const addMenu = (
|
||||
<ServicesAddMenu
|
||||
onAddService={() => handleOpenCreateService()}
|
||||
onAddGroup={() => setCreateGroupSheetOpen(true)}
|
||||
/>
|
||||
)
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
{addMenu}
|
||||
<CatalogBoardToggle view={view} onViewChange={setView} />
|
||||
<Button type="button" variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Создать группу сервисов
|
||||
</Button>
|
||||
<Button type="button" onClick={() => handleOpenCreateService()}>
|
||||
Создать сервис
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -707,7 +698,9 @@ function ServicesPage() {
|
||||
<PageHeader title="Сервисы" description={pageDescription} />
|
||||
<EmptyState
|
||||
title="Не удалось загрузить сервисы"
|
||||
description={error?.message}
|
||||
description={
|
||||
error?.message ?? 'Не удалось загрузить сервисы. Повторите запрос.'
|
||||
}
|
||||
action={
|
||||
<Button type="button" variant="outline" onClick={() => refetch()}>
|
||||
Повторить
|
||||
@@ -726,6 +719,18 @@ function ServicesPage() {
|
||||
description={pageDescription}
|
||||
actions={headerActions}
|
||||
/>
|
||||
{downService ? (
|
||||
<IncidentAlert
|
||||
title={downServiceTitle(downService)}
|
||||
description={downServiceDescription(downService)}
|
||||
onShow={() =>
|
||||
navigate({
|
||||
to: '/services/$serviceId',
|
||||
params: { serviceId: String(downService.id) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'board' ? (
|
||||
<>
|
||||
<ServicesBulkToolbar
|
||||
@@ -738,16 +743,20 @@ function ServicesPage() {
|
||||
{catalogRows.length === 0 && (data?.groups.length ?? 0) === 0 ? (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
title="Нет сервисов"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||
: 'Сначала создайте группу, затем добавьте сервисы.'
|
||||
: 'Создайте сервис и добавьте поддомен с нодами.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{addMenu}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenCreateService()}
|
||||
>
|
||||
Создать сервис
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -800,9 +809,13 @@ function ServicesPage() {
|
||||
onDeleteGroup={setDeletingGroup}
|
||||
onAddServiceToGroup={handleOpenCreateService}
|
||||
emptyAction={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{addMenu}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenCreateService()}
|
||||
>
|
||||
Создать сервис
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
@@ -811,3 +824,60 @@ function ServicesPage() {
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function pickHeaviestDown(rows: ServiceCatalogRow[]): ServiceView | null {
|
||||
const downs = rows
|
||||
.map((row) => row.service)
|
||||
.filter((service) => service.health_status === 'down')
|
||||
if (downs.length === 0) return null
|
||||
return downs.sort((left, right) => {
|
||||
const weight = downWeight(right) - downWeight(left)
|
||||
if (weight !== 0) return weight
|
||||
return left.name.localeCompare(right.name, 'ru')
|
||||
})[0]
|
||||
}
|
||||
|
||||
function downWeight(service: ServiceView): number {
|
||||
const rows = service.ip_health ?? []
|
||||
const down = rows.filter((row) => row.status === 'down').length
|
||||
const allDown = rows.length > 0 && down === rows.length
|
||||
return (allDown ? 1000 : 0) + down
|
||||
}
|
||||
|
||||
function downServiceTitle(service: ServiceView): string {
|
||||
const rows = service.ip_health ?? []
|
||||
const allDown =
|
||||
rows.length > 0 && rows.every((row) => row.status === 'down')
|
||||
return allDown
|
||||
? `${service.name}: все ноды недоступны.`
|
||||
: `${service.name} недоступен.`
|
||||
}
|
||||
|
||||
function downServiceDescription(service: ServiceView): string {
|
||||
const fqdn = serviceDisplayFqdns(service).find((value) => value && value !== '—')
|
||||
const checkedAt = latestCheck(service)
|
||||
const errorText = (service.ip_health ?? []).find(
|
||||
(row) => row.status === 'down' && row.last_error,
|
||||
)?.last_error
|
||||
const parts = [
|
||||
fqdn,
|
||||
checkedAt ? `последняя проверка ${formatDate(checkedAt)}` : null,
|
||||
errorText,
|
||||
].filter((part): part is string => Boolean(part))
|
||||
return parts.join(' · ') || 'Статус Down.'
|
||||
}
|
||||
|
||||
function latestCheck(service: ServiceView): string | null {
|
||||
let latestMs = Number.NEGATIVE_INFINITY
|
||||
let latest: string | null = null
|
||||
for (const row of service.ip_health ?? []) {
|
||||
if (!row.last_checked_at) continue
|
||||
const ms = new Date(
|
||||
sqliteUtcToIso(row.last_checked_at) ?? row.last_checked_at,
|
||||
).getTime()
|
||||
if (Number.isNaN(ms) || ms <= latestMs) continue
|
||||
latestMs = ms
|
||||
latest = row.last_checked_at
|
||||
}
|
||||
return latest
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
|
||||
import { HeartPulseIcon, GlobeIcon, TriangleAlertIcon } from 'lucide-react'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
@@ -57,6 +58,33 @@ type FormValues = z.infer<typeof formSchema>
|
||||
type GlobalpingValues = z.infer<typeof globalpingSchema>
|
||||
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
|
||||
|
||||
function toHealthFormValues(data: {
|
||||
healthCheckCron: string
|
||||
healthDegradedFailures: number
|
||||
healthDownFailures: number
|
||||
healthLatencyWarnMs: number
|
||||
healthSuccessRecoveries: number
|
||||
}): FormValues {
|
||||
return {
|
||||
healthCheckCron: data.healthCheckCron,
|
||||
healthDegradedFailures: data.healthDegradedFailures,
|
||||
healthDownFailures: data.healthDownFailures,
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
}
|
||||
}
|
||||
|
||||
function toGlobalpingFormValues(data: {
|
||||
globalpingLocations?: string
|
||||
globalpingLimit?: number
|
||||
}): GlobalpingValues {
|
||||
return {
|
||||
globalpingToken: '',
|
||||
globalpingLocations: data.globalpingLocations || 'World',
|
||||
globalpingLimit: data.globalpingLimit ?? 3,
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsResponse = FormValues & {
|
||||
id: string
|
||||
healthWorkerUrl?: string
|
||||
@@ -161,18 +189,8 @@ function HealthSettingsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
form.reset({
|
||||
healthCheckCron: data.healthCheckCron,
|
||||
healthDegradedFailures: data.healthDegradedFailures,
|
||||
healthDownFailures: data.healthDownFailures,
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
})
|
||||
gpForm.reset({
|
||||
globalpingToken: '',
|
||||
globalpingLocations: data.globalpingLocations || 'World',
|
||||
globalpingLimit: data.globalpingLimit ?? 3,
|
||||
})
|
||||
form.reset(toHealthFormValues(data))
|
||||
gpForm.reset(toGlobalpingFormValues(data))
|
||||
}, [data, form, gpForm])
|
||||
|
||||
const saveMut = useMutation({
|
||||
@@ -184,7 +202,8 @@ function HealthSettingsPage() {
|
||||
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSuccess: (saved) => {
|
||||
form.reset(toHealthFormValues(saved))
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Настройки health-check сохранены')
|
||||
},
|
||||
@@ -201,13 +220,10 @@ function HealthSettingsPage() {
|
||||
? { globalpingToken: values.globalpingToken.trim() }
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSuccess: (saved) => {
|
||||
gpForm.reset(toGlobalpingFormValues(saved))
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Настройки Globalping сохранены')
|
||||
gpForm.reset({
|
||||
...gpForm.getValues(),
|
||||
globalpingToken: '',
|
||||
})
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
@@ -224,8 +240,21 @@ function HealthSettingsPage() {
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
|
||||
})
|
||||
|
||||
const healthDirty = Boolean(data) && form.formState.isDirty
|
||||
const globalpingDirty = Boolean(data) && gpForm.formState.isDirty
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{healthDirty || globalpingDirty ? (
|
||||
<Alert variant="warning" role="status">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Есть несохранённые изменения</AlertTitle>
|
||||
<AlertDescription>
|
||||
Поля health-check или Globalping отличаются от последних сохранённых
|
||||
значений. Сохраните их или сбросьте к данным с сервера.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<form
|
||||
className="flex w-full flex-col gap-4"
|
||||
onSubmit={(event) =>
|
||||
@@ -360,11 +389,27 @@ function HealthSettingsPage() {
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<FrameFooter className="flex flex-row justify-end">
|
||||
<FrameFooter className="flex flex-row justify-end gap-2">
|
||||
<ConfirmDialog
|
||||
title="Сбросить изменения?"
|
||||
description="Поля health-check вернутся к последним сохранённым значениям с сервера. Несохранённые правки будут отброшены."
|
||||
confirmLabel="Сбросить"
|
||||
disabled={!healthDirty || saveMut.isPending}
|
||||
onConfirm={() => form.reset()}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!healthDirty || saveMut.isPending}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={saveMut.isPending}
|
||||
disabled={isLoading || !form.formState.isDirty}
|
||||
disabled={isLoading || !healthDirty}
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
@@ -571,11 +616,27 @@ function HealthSettingsPage() {
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<FrameFooter className="flex flex-row justify-end">
|
||||
<FrameFooter className="flex flex-row justify-end gap-2">
|
||||
<ConfirmDialog
|
||||
title="Сбросить изменения?"
|
||||
description="Локации, число проб и поле токена вернутся к последним сохранённым значениям. Введённый новый токен будет очищен."
|
||||
confirmLabel="Сбросить"
|
||||
disabled={!globalpingDirty || saveGpMut.isPending}
|
||||
onConfirm={() => gpForm.reset()}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!globalpingDirty || saveGpMut.isPending}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={saveGpMut.isPending}
|
||||
disabled={isLoading || !gpForm.formState.isDirty}
|
||||
disabled={isLoading || !globalpingDirty}
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
html[data-contrast='high'] {
|
||||
--border: color-mix(in oklch, var(--foreground) 46%, var(--background));
|
||||
--input: color-mix(in oklch, var(--foreground) 46%, var(--background));
|
||||
--ring: var(--foreground);
|
||||
--muted-foreground: color-mix(in oklch, var(--foreground) 78%, var(--background));
|
||||
--sidebar-border: color-mix(in oklch, var(--foreground) 46%, var(--sidebar));
|
||||
}
|
||||
|
||||
html[data-density='compact'] {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
html[data-density='compact'] #main-content > div {
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
+4
-3
@@ -44,9 +44,10 @@ health-check работают на двух уровнях:
|
||||
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
|
||||
|
||||
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
||||
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
||||
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
||||
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
||||
работает как `round_robin` (одна A на IP). В A-пул попадает всё, кроме **Down**
|
||||
(`unknown` / checking / Slow возвращаются в DNS на первой успешной пробе).
|
||||
Бейдж Healthy — `UNHEALTHY → CHECKING → HEALTHY` после `HEALTH_SUCCESS_RECOVERIES`
|
||||
(default 2). Пороги и cron движка задаются в
|
||||
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||
|
||||
### Источники проб: Local, Cloudflare Worker, Globalping
|
||||
|
||||
Vendored
+7
-1
@@ -10074,6 +10074,11 @@ type ServiceIpHealthRow = {
|
||||
};
|
||||
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
|
||||
declare function listIpHealthByServiceIds(db: Db, serviceIds: number[]): Map<number, ServiceIpHealthRow[]>;
|
||||
/**
|
||||
* Latest probe per service+IP+provider from health_probe_log, then any-up per IP.
|
||||
* Display overlay — hysteresis in ip_health_status is unchanged (DNS).
|
||||
*/
|
||||
declare function listLatestLiveHealthByServiceIds(db: Db, serviceIds: number[]): Map<number, ServiceIpHealthRow[]>;
|
||||
declare function mergeHealthAggregates(parts: Array<HealthAggregate | undefined | null>): HealthAggregate;
|
||||
declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null;
|
||||
declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number, extras?: {
|
||||
@@ -10298,6 +10303,7 @@ declare const repos_listHealthChecks: typeof listHealthChecks;
|
||||
declare const repos_listHealthProbeLogForService: typeof listHealthProbeLogForService;
|
||||
declare const repos_listIpHealthByServiceIds: typeof listIpHealthByServiceIds;
|
||||
declare const repos_listIpHealthStatus: typeof listIpHealthStatus;
|
||||
declare const repos_listLatestLiveHealthByServiceIds: typeof listLatestLiveHealthByServiceIds;
|
||||
declare const repos_listNodes: typeof listNodes;
|
||||
declare const repos_listNotificationLog: typeof listNotificationLog;
|
||||
declare const repos_listOriginIpsForFqdn: typeof listOriginIpsForFqdn;
|
||||
@@ -10345,7 +10351,7 @@ declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck;
|
||||
declare const repos_upsertIpHealthStatus: typeof upsertIpHealthStatus;
|
||||
declare const repos_upsertSubdomain: typeof upsertSubdomain;
|
||||
declare namespace repos {
|
||||
export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIpRows as listServiceIpRows, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDLine truncated
|
||||
export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listLatestLiveHealthByServiceIds as listLatestLiveHealthByServiceIds, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIpRows as listServiceIpRows, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroLine truncated
|
||||
}
|
||||
|
||||
export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, type HealthEngineFallbacks, type HealthEngineSettings, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, bindingNodes, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, failoverLog, getAppSettings, getAppSettingsSecrets, groups, healthCheck, healthChecks, healthProbeLog, ipHealthStatus, listAudit, nodes, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings };
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user