Enhance CDN Manager: Add Cloudflare API token support, update documentation, and introduce new routes for managing nodes, aliases, and topology. Improve UI components and status badges for better user experience.
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 27s

This commit is contained in:
Denozordec
2026-09-04 12:49:51 +07:00
parent d480325357
commit 8700dc2957
47 changed files with 10550 additions and 69 deletions
+1037 -8
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -10,10 +10,12 @@ import { loadConfig } from "./config.js";
import authPlugin from "./plugins/auth.js";
import { requireAuth } from "./plugins/auth.js";
import corsPlugin from "./plugins/cors.js";
import cfClientPlugin from "./plugins/cf-client.js";
import dbPlugin from "./plugins/db.js";
import errorHandlerPlugin from "./plugins/error-handler.js";
import { authRoutes, healthRoutes } from "./routes/health.js";
import { settingsRoutes } from "./routes/settings.js";
import { fleetRoutes } from "./routes/fleet.js";
export interface BuildAppOptions {
config?: AppConfig;
@@ -31,7 +33,9 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.setSerializerCompiler(serializerCompiler);
await app.register(import("@fastify/sensible"));
await app.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
await app.register(import("@fastify/helmet"), {
contentSecurityPolicy: false,
});
await app.register(import("@fastify/rate-limit"), {
max: 300,
timeWindow: "1 minute",
@@ -39,6 +43,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(corsPlugin);
await app.register(errorHandlerPlugin);
await app.register(dbPlugin, { config, memory: opts.memory });
await app.register(cfClientPlugin, { config });
await app.register(authPlugin, { config });
await app.register(healthRoutes);
@@ -48,6 +53,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
async (protectedApi) => {
protectedApi.addHook("onRequest", requireAuth);
await protectedApi.register(settingsRoutes);
await protectedApi.register(fleetRoutes);
},
{ prefix: "/api/v1" },
);
+3
View File
@@ -15,6 +15,8 @@ export interface AppConfig {
authPortalUrl: string;
/** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */
authAuditIngestSecret: string | null;
/** Cloudflare API token (Zone DNS Edit + Zone Read) */
cloudflareApiToken: string;
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
@@ -52,5 +54,6 @@ export function loadConfig(): AppConfig {
authAuditIngestSecret:
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
(!isProd ? "dev-audit-ingest-secret" : null),
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
};
}
+63
View File
@@ -0,0 +1,63 @@
import type {
CfDnsRecord,
CfZone,
CreateDnsRecordPayload,
PatchDnsRecordPayload,
} from "@cdnmanager/shared";
import { createDnsAdapter } from "./cloudflare/dns-service.js";
import { createZoneAdapter } from "./cloudflare/zone-service.js";
export class CloudflareClient {
private readonly zones;
private readonly dns;
readonly token: string;
constructor(token: string) {
this.token = token.trim();
this.zones = createZoneAdapter(this.token);
this.dns = createDnsAdapter(this.token);
}
get isConfigured(): boolean {
return this.token.length > 0;
}
listZones(): Promise<CfZone[]> {
return this.zones.listZones();
}
getZone(zoneId: string): Promise<CfZone> {
return this.zones.getZone(zoneId);
}
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId);
}
createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.createDnsRecord(zoneId, payload);
}
updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.updateDnsRecord(zoneId, recordId, payload);
}
patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.patchDnsRecord(zoneId, recordId, payload);
}
deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
return this.dns.deleteDnsRecord(zoneId, recordId);
}
}
+114
View File
@@ -0,0 +1,114 @@
import type {
CfDnsRecord,
CreateDnsRecordPayload,
PatchDnsRecordPayload,
} from "@cdnmanager/shared";
import {
CF_API_BASE,
handleCfResponse,
mapCloudflareFailure,
withRetry,
} from "./http.js";
export function createDnsAdapter(token: string) {
return {
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure(
"list_dns_records",
response.status,
String(response.status),
);
}
const batch = await handleCfResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
},
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "create_dns_record");
},
async updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "update_dns_record");
},
async patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "patch_dns_record");
},
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
await handleCfResponse(response, "delete_dns_record");
},
};
}
+83
View File
@@ -0,0 +1,83 @@
import { AppError } from "../../errors.js";
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
export interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export function parseRetryAfter(headers: Headers): number | null {
const value = headers.get("retry-after");
if (!value) return null;
const seconds = Number(value);
return Number.isFinite(seconds) ? seconds * 1000 : null;
}
export async function withRetry<T>(
operation: () => Promise<T>,
maxAttempts = 3,
): Promise<T> {
let delay = 500;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
await new Promise((r) => setTimeout(r, delay));
delay *= 2;
}
}
}
throw lastError;
}
export function mapCloudflareFailure(
operation: string,
status: number,
message: string,
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
return AppError.cloudflareAuthFailed(
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
);
}
if (status === 429 || lower.includes("rate limit")) {
return AppError.rateLimited();
}
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
return AppError.zoneNotFound();
}
if (operation.includes("dns") || operation.includes("dns_record")) {
return AppError.dnsUpdateFailed(
`Не удалось обновить DNS в Cloudflare: ${message}`,
);
}
return AppError.cloudflare(`${operation}: ${message}`);
}
export async function handleCfResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.rateLimited(
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
throw mapCloudflareFailure(operation, response.status, msg);
}
if (body.result === undefined) {
throw mapCloudflareFailure(operation, response.status, "empty result");
}
return body.result;
}
@@ -0,0 +1,48 @@
import type { CfZone } from "@cdnmanager/shared";
import {
CF_API_BASE,
handleCfResponse,
mapCloudflareFailure,
withRetry,
} from "./http.js";
export function createZoneAdapter(token: string) {
return {
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure(
"list_zones",
response.status,
String(response.status),
);
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
},
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "get_zone");
},
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { FastifyPluginAsync } from "fastify";
import fp from "fastify-plugin";
import type { AppConfig } from "../config.js";
import { CloudflareClient } from "../lib/cf-client.js";
declare module "fastify" {
interface FastifyInstance {
cf: CloudflareClient;
}
}
const cfClientPlugin: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const cf = new CloudflareClient(opts.config.cloudflareApiToken);
app.decorate("cf", cf);
};
export default fp(cfClientPlugin, { name: "cf-client" });
+336
View File
@@ -0,0 +1,336 @@
import type { FastifyPluginAsync } from "fastify";
import {
aliasCreateSchema,
aliasPatchSchema,
aliasRetargetSchema,
nodeCreateSchema,
nodePatchSchema,
orphanIgnoreSchema,
syncApplySchema,
zoneCreateSchema,
zonePatchSchema,
} from "@cdnmanager/shared";
import {
createAlias,
createNode,
createZone,
dashboardCounts,
deleteAlias,
deleteNode,
deleteZone,
getAlias,
getNode,
getZone,
ignoreOrphan,
listAliases,
listIgnoredOrphans,
listLocations,
listNodes,
listSyncJobs,
listZones,
unignoreOrphan,
updateAlias,
updateNode,
updateZone,
} from "@cdnmanager/db";
import { AppError } from "../errors.js";
import {
buildHostname,
isValidIpv4,
isValidIpv6,
normalizeFqdn,
} from "../services/naming.js";
import {
applyZoneDiff,
exportBindZone,
retargetAlias,
syncZonePull,
} from "../services/sync.js";
export const fleetRoutes: FastifyPluginAsync = async (app) => {
app.get("/locations", async () => listLocations(app.db));
app.get("/zones", async () => listZones(app.db));
app.post("/zones", async (req) => {
const body = zoneCreateSchema.parse(req.body);
return createZone(app.db, body);
});
app.get<{ Params: { zoneId: string } }>("/zones/:zoneId", async (req) =>
getZone(app.db, req.params.zoneId),
);
app.patch<{ Params: { zoneId: string } }>("/zones/:zoneId", async (req) => {
const body = zonePatchSchema.parse(req.body);
return updateZone(app.db, req.params.zoneId, body);
});
app.delete<{ Params: { zoneId: string } }>("/zones/:zoneId", async (req) => {
deleteZone(app.db, req.params.zoneId);
return { ok: true };
});
app.get("/cloudflare/zones", async () => {
if (!app.cf.isConfigured) {
throw AppError.cloudflareAuthFailed("CLOUDFLARE_API_TOKEN не задан");
}
return app.cf.listZones();
});
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/sync",
async (req) => syncZonePull(app.db, app.cf, req.params.zoneId),
);
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/apply",
async (req) => {
const body = syncApplySchema.parse(req.body ?? {});
return applyZoneDiff(app.db, app.cf, req.params.zoneId, body.opIds);
},
);
app.get<{ Params: { zoneId: string } }>(
"/zones/:zoneId/sync-jobs",
async (req) => listSyncJobs(app.db, req.params.zoneId),
);
app.get<{ Params: { zoneId: string } }>(
"/zones/:zoneId/export/bind",
async (req) => ({
zoneName: getZone(app.db, req.params.zoneId).name,
content: exportBindZone(app.db, req.params.zoneId),
}),
);
app.get<{ Params: { zoneId: string } }>(
"/zones/:zoneId/orphans/ignored",
async (req) => listIgnoredOrphans(app.db, req.params.zoneId),
);
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/orphans/ignore",
async (req) => {
const body = orphanIgnoreSchema.parse(req.body);
ignoreOrphan(
app.db,
req.params.zoneId,
normalizeFqdn(body.recordName),
body.recordType.toUpperCase(),
);
return { ok: true };
},
);
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/orphans/unignore",
async (req) => {
const body = orphanIgnoreSchema.parse(req.body);
unignoreOrphan(
app.db,
req.params.zoneId,
normalizeFqdn(body.recordName),
body.recordType.toUpperCase(),
);
return { ok: true };
},
);
app.get("/nodes", async (req) => {
const q = req.query as Record<string, string | undefined>;
return listNodes(app.db, {
zoneId: q.zoneId,
locationId: q.locationId,
role: q.role,
syncStatus: q.syncStatus,
q: q.q,
});
});
app.post("/nodes", async (req) => {
const body = nodeCreateSchema.parse(req.body);
if (!isValidIpv4(body.ipv4)) throw AppError.invalidIp("Некорректный IPv4");
if (body.ipv6 && !isValidIpv6(body.ipv6)) {
throw AppError.invalidIp("Некорректный IPv6");
}
const zone = getZone(app.db, body.zoneId);
const loc = listLocations(app.db).find((l) => l.id === body.locationId);
if (!loc) throw AppError.notFound("location not found");
const hostname =
body.hostname?.trim() ||
buildHostname({
template: zone.namingTemplate,
locationCode: loc.code,
role: body.role,
indexNum: body.indexNum,
zoneName: zone.name,
providerTag: body.providerTag,
});
return createNode(app.db, {
zoneId: body.zoneId,
locationId: body.locationId,
hostname: normalizeFqdn(hostname),
role: body.role,
indexNum: body.indexNum,
providerTag: body.providerTag,
notes: body.notes,
ipv4: body.ipv4,
ipv6: body.ipv6,
});
});
app.get<{ Params: { nodeId: string } }>("/nodes/:nodeId", async (req) =>
getNode(app.db, req.params.nodeId),
);
app.patch<{ Params: { nodeId: string } }>("/nodes/:nodeId", async (req) => {
const body = nodePatchSchema.parse(req.body);
if (body.ipv4 && !isValidIpv4(body.ipv4)) {
throw AppError.invalidIp("Некорректный IPv4");
}
if (body.ipv6 && !isValidIpv6(body.ipv6)) {
throw AppError.invalidIp("Некорректный IPv6");
}
return updateNode(app.db, req.params.nodeId, {
...body,
hostname: body.hostname ? normalizeFqdn(body.hostname) : undefined,
});
});
app.delete<{ Params: { nodeId: string } }>("/nodes/:nodeId", async (req) => {
deleteNode(app.db, req.params.nodeId);
return { ok: true };
});
app.get("/aliases", async (req) => {
const q = req.query as Record<string, string | undefined>;
return listAliases(app.db, {
zoneId: q.zoneId,
purpose: q.purpose,
syncStatus: q.syncStatus,
q: q.q,
});
});
app.post("/aliases", async (req) => {
const body = aliasCreateSchema.parse(req.body);
return createAlias(app.db, {
zoneId: body.zoneId,
name: normalizeFqdn(body.name),
purpose: body.purpose,
mode: body.mode,
targetNodeId: body.targetNodeId,
});
});
app.get<{ Params: { aliasId: string } }>("/aliases/:aliasId", async (req) =>
getAlias(app.db, req.params.aliasId),
);
app.patch<{ Params: { aliasId: string } }>(
"/aliases/:aliasId",
async (req) => {
const body = aliasPatchSchema.parse(req.body);
return updateAlias(app.db, req.params.aliasId, {
...body,
name: body.name ? normalizeFqdn(body.name) : undefined,
});
},
);
app.post<{ Params: { aliasId: string } }>(
"/aliases/:aliasId/retarget",
async (req) => {
const body = aliasRetargetSchema.parse(req.body);
return retargetAlias(
app.db,
app.cf,
req.params.aliasId,
body.targetNodeId,
);
},
);
app.delete<{ Params: { aliasId: string } }>(
"/aliases/:aliasId",
async (req) => {
deleteAlias(app.db, req.params.aliasId);
return { ok: true };
},
);
app.get("/dashboard/stats", async () => {
const base = dashboardCounts(app.db);
// proxy/orphan from last sync jobs — approximate via sync_status
const nodes = listNodes(app.db);
const aliases = listAliases(app.db);
const proxyViolations = [...nodes, ...aliases].filter(
(x) => x.syncStatus === "drift" && (x.lastError ?? "").includes("Proxy"),
).length;
const orphans = listZones(app.db).length
? listSyncJobs(app.db, listZones(app.db)[0]!.id)
.flatMap((j) => (j.diff as Array<{ kind: string }>) ?? [])
.filter((o) => o.kind === "orphan").length
: 0;
return {
nodes: base.nodes,
aliases: base.aliases,
syncOk: base.syncOk,
drift: base.drift,
proxyViolations,
orphans,
lastSyncAt: base.lastSyncAt,
nodesWithoutIp: base.nodesWithoutIp,
brokenAliases: base.brokenAliases,
};
});
app.get("/topology", async (req) => {
const q = req.query as Record<string, string | undefined>;
const zoneId = q.zoneId;
const locs = listLocations(app.db);
const nodeList = listNodes(app.db, zoneId ? { zoneId } : {});
const aliasList = listAliases(app.db, zoneId ? { zoneId } : {});
return {
locations: locs,
nodes: nodeList.map((n) => ({
id: n.id,
hostname: n.hostname,
role: n.role,
locationCode: n.locationCode ?? "",
ipv4: n.addresses.find((a) => a.family === "v4")?.ip ?? null,
syncStatus: n.syncStatus,
})),
edges: aliasList.map((a) => ({
id: a.id,
aliasName: a.name,
purpose: a.purpose,
fromNodeId: a.targetNodeId,
toHostname: a.targetHostname ?? "",
})),
};
});
app.get("/naming/preview", async (req) => {
const q = req.query as Record<string, string | undefined>;
if (!q.zoneId || !q.locationId || !q.role) {
throw AppError.validation("zoneId, locationId, role обязательны");
}
const zone = getZone(app.db, q.zoneId);
const loc = listLocations(app.db).find((l) => l.id === q.locationId);
if (!loc) throw AppError.notFound("location not found");
const indexNum = Number(q.indexNum ?? "1") || 1;
return {
hostname: buildHostname({
template: q.template || zone.namingTemplate,
locationCode: loc.code,
role: q.role,
indexNum,
zoneName: zone.name,
providerTag: q.providerTag,
}),
};
});
};
+10 -2
View File
@@ -5,7 +5,11 @@ import { AppError } from "../errors.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
return getAppSettings(request.server.db);
const settings = getAppSettings(request.server.db);
return {
...settings,
cloudflareConfigured: request.server.cf.isConfigured,
};
});
app.patch("/settings", async (request) => {
@@ -15,6 +19,10 @@ export async function settingsRoutes(app: FastifyInstance) {
parsed.error.issues[0]?.message ?? "некорректные настройки",
);
}
return updateAppSettings(request.server.db, parsed.data);
const settings = updateAppSettings(request.server.db, parsed.data);
return {
...settings,
cloudflareConfigured: request.server.cf.isConfigured,
};
});
}
+43
View File
@@ -0,0 +1,43 @@
/** Build canonical hostname: {loc}-{role}{nn}.{zone} */
export function buildHostname(opts: {
template?: string;
locationCode: string;
role: string;
indexNum: number;
zoneName: string;
providerTag?: string | null;
}): string {
const nn = String(opts.indexNum).padStart(2, "0");
const template = opts.template ?? "{loc}-{role}{nn}.{zone}";
let host = template
.replaceAll("{loc}", opts.locationCode.toLowerCase())
.replaceAll("{role}", opts.role.toLowerCase())
.replaceAll("{nn}", nn)
.replaceAll("{zone}", opts.zoneName.toLowerCase());
if (opts.providerTag) {
// optional: msk-ih-gw01 if template has {provider}
host = host.replaceAll("{provider}", opts.providerTag.toLowerCase());
} else {
host = host.replaceAll("-{provider}", "").replaceAll("{provider}", "");
}
return host.replace(/\.$/, "");
}
export function normalizeFqdn(name: string): string {
return name.trim().toLowerCase().replace(/\.$/, "");
}
export function isValidIpv4(ip: string): boolean {
const parts = ip.split(".");
if (parts.length !== 4) return false;
return parts.every((p) => {
const n = Number(p);
return Number.isInteger(n) && n >= 0 && n <= 255 && String(n) === p;
});
}
export function isValidIpv6(ip: string): boolean {
// lightweight check — enough for UI validation
return /^[0-9a-f:]+$/i.test(ip) && ip.includes(":");
}
+546
View File
@@ -0,0 +1,546 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@cdnmanager/db";
import type { SyncDiffOp } from "@cdnmanager/shared";
import {
getAlias,
getNode,
getZone,
listAliases,
listIgnoredOrphans,
listNodes,
updateAlias,
updateNode,
updateZone,
createSyncJob,
updateSyncJob,
getSyncJob,
addSyncEvent,
} from "@cdnmanager/db";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { normalizeFqdn } from "./naming.js";
function opId() {
return `op-${randomUUID().slice(0, 8)}`;
}
function findObserved(
records: Array<{
id?: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
}>,
type: string,
name: string,
) {
const n = normalizeFqdn(name);
return records.find(
(r) => r.type === type && normalizeFqdn(r.name) === n,
);
}
export async function buildZoneDiff(
db: Db,
cf: CloudflareClient,
zoneId: string,
): Promise<SyncDiffOp[]> {
const zone = getZone(db, zoneId);
if (!zone.cfZoneId) {
throw AppError.validation("У зоны не задан cfZoneId Cloudflare");
}
if (!cf.isConfigured) {
throw AppError.cloudflareAuthFailed(
"CLOUDFLARE_API_TOKEN не задан. Добавьте токен в окружение API.",
);
}
const observed = await cf.listDnsRecords(zone.cfZoneId);
const nodeList = listNodes(db, { zoneId });
const aliasList = listAliases(db, { zoneId });
const ignored = new Set(
listIgnoredOrphans(db, zoneId).map(
(o) => `${o.recordType}:${normalizeFqdn(o.recordName)}`,
),
);
const ops: SyncDiffOp[] = [];
const managedKeys = new Set<string>();
for (const node of nodeList) {
const ttl = zone.defaultTtl;
const v4 = node.addresses.find((a) => a.family === "v4");
const v6 = node.addresses.find((a) => a.family === "v6");
if (v4) {
const key = `A:${normalizeFqdn(node.hostname)}`;
managedKeys.add(key);
const obs = findObserved(observed, "A", node.hostname);
const desired = {
type: "A",
name: node.hostname,
content: v4.ip,
ttl,
proxied: false,
};
if (!obs) {
ops.push({
id: opId(),
kind: "create",
entityType: "node_a",
entityId: node.id,
recordName: node.hostname,
recordType: "A",
desired,
observed: null,
detail: "A-запись отсутствует в Cloudflare",
});
} else if (
obs.content !== v4.ip ||
obs.proxied === true ||
(obs.ttl !== 1 && obs.ttl !== ttl)
) {
ops.push({
id: opId(),
kind: obs.proxied === true ? "proxy_violation" : "update",
entityType: "node_a",
entityId: node.id,
recordName: node.hostname,
recordType: "A",
desired,
observed: {
id: obs.id,
type: obs.type,
name: obs.name,
content: obs.content,
ttl: obs.ttl,
proxied: obs.proxied ?? false,
},
detail:
obs.proxied === true
? "Proxy включён — для туннелей нужен DNS-only"
: "Содержимое/TTL отличается от desired",
});
} else {
ops.push({
id: opId(),
kind: "noop",
entityType: "node_a",
entityId: node.id,
recordName: node.hostname,
recordType: "A",
desired,
observed: { id: obs.id, content: obs.content },
});
}
}
if (v6) {
const key = `AAAA:${normalizeFqdn(node.hostname)}`;
managedKeys.add(key);
const obs = findObserved(observed, "AAAA", node.hostname);
const desired = {
type: "AAAA",
name: node.hostname,
content: v6.ip,
ttl,
proxied: false,
};
if (!obs) {
ops.push({
id: opId(),
kind: "create",
entityType: "node_aaaa",
entityId: node.id,
recordName: node.hostname,
recordType: "AAAA",
desired,
observed: null,
});
} else if (obs.content !== v6.ip || obs.proxied === true) {
ops.push({
id: opId(),
kind: obs.proxied === true ? "proxy_violation" : "update",
entityType: "node_aaaa",
entityId: node.id,
recordName: node.hostname,
recordType: "AAAA",
desired,
observed: {
id: obs.id,
content: obs.content,
proxied: obs.proxied ?? false,
},
});
}
}
}
for (const alias of aliasList) {
const target = getNode(db, alias.targetNodeId);
const key = `CNAME:${normalizeFqdn(alias.name)}`;
managedKeys.add(key);
const obs = findObserved(observed, "CNAME", alias.name);
const desired = {
type: "CNAME",
name: alias.name,
content: target.hostname,
ttl: zone.defaultTtl,
proxied: false,
};
if (!obs) {
ops.push({
id: opId(),
kind: "create",
entityType: "alias",
entityId: alias.id,
recordName: alias.name,
recordType: "CNAME",
desired,
observed: null,
});
} else if (
normalizeFqdn(obs.content) !== normalizeFqdn(target.hostname) ||
obs.proxied === true
) {
ops.push({
id: opId(),
kind: obs.proxied === true ? "proxy_violation" : "update",
entityType: "alias",
entityId: alias.id,
recordName: alias.name,
recordType: "CNAME",
desired,
observed: {
id: obs.id,
content: obs.content,
proxied: obs.proxied ?? false,
},
});
} else {
ops.push({
id: opId(),
kind: "noop",
entityType: "alias",
entityId: alias.id,
recordName: alias.name,
recordType: "CNAME",
desired,
observed: { id: obs.id, content: obs.content },
});
}
}
for (const rec of observed) {
if (!["A", "AAAA", "CNAME"].includes(rec.type)) continue;
const key = `${rec.type}:${normalizeFqdn(rec.name)}`;
if (managedKeys.has(key)) continue;
if (ignored.has(key)) continue;
ops.push({
id: opId(),
kind: "orphan",
entityType: "orphan",
entityId: null,
recordName: rec.name,
recordType: rec.type,
desired: null,
observed: {
id: rec.id,
type: rec.type,
name: rec.name,
content: rec.content,
proxied: rec.proxied ?? false,
},
detail: "Запись в Cloudflare вне inventory",
});
}
return ops;
}
export async function syncZonePull(
db: Db,
cf: CloudflareClient,
zoneId: string,
) {
const jobId = createSyncJob(db, zoneId);
updateSyncJob(db, jobId, { status: "running" });
try {
const diff = await buildZoneDiff(db, cf, zoneId);
for (const op of diff) {
if (op.kind === "noop") continue;
addSyncEvent(db, jobId, {
kind: op.kind,
recordName: op.recordName,
recordType: op.recordType,
detail: op.detail,
});
}
// Update local sync_status from diff
for (const op of diff) {
if (!op.entityId) continue;
const status =
op.kind === "noop"
? "ok"
: op.kind === "create"
? "missing"
: op.kind === "proxy_violation" || op.kind === "update"
? "drift"
: "error";
if (op.entityType === "alias") {
updateAlias(db, op.entityId, {
syncStatus: status,
cfRecordId:
(op.observed?.id as string | undefined) ??
getAlias(db, op.entityId).cfRecordId,
});
} else if (op.entityType === "node_a") {
updateNode(db, op.entityId, {
syncStatus: status === "ok" ? status : status,
cfARecordId:
(op.observed?.id as string | undefined) ??
getNode(db, op.entityId).cfARecordId,
});
} else if (op.entityType === "node_aaaa") {
updateNode(db, op.entityId, {
cfAaaaRecordId:
(op.observed?.id as string | undefined) ??
getNode(db, op.entityId).cfAaaaRecordId,
syncStatus: status,
});
}
}
// Mark fully ok nodes/aliases that only had noop
const byEntity = new Map<string, SyncDiffOp[]>();
for (const op of diff) {
if (!op.entityId) continue;
const list = byEntity.get(op.entityId) ?? [];
list.push(op);
byEntity.set(op.entityId, list);
}
for (const [entityId, list] of byEntity) {
const actionable = list.filter((o) => o.kind !== "noop");
if (actionable.length === 0) {
const sample = list[0];
if (sample?.entityType === "alias") {
updateAlias(db, entityId, {
syncStatus: "ok",
cfRecordId: (sample.observed?.id as string) ?? undefined,
lastError: null,
});
} else if (sample?.entityType.startsWith("node")) {
updateNode(db, entityId, {
syncStatus: "ok",
lastError: null,
});
}
}
}
updateSyncJob(db, jobId, {
status: "done",
diffJson: JSON.stringify(diff),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
updateZone(db, zoneId, {
lastSyncAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
return getSyncJob(db, jobId);
} catch (err) {
updateSyncJob(db, jobId, {
status: "failed",
error: err instanceof Error ? err.message : String(err),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
throw err;
}
}
export async function applyZoneDiff(
db: Db,
cf: CloudflareClient,
zoneId: string,
opIds?: string[],
) {
const zone = getZone(db, zoneId);
if (!zone.cfZoneId) {
throw AppError.validation("У зоны не задан cfZoneId Cloudflare");
}
const diff = await buildZoneDiff(db, cf, zoneId);
const selected = opIds?.length
? diff.filter((o) => opIds.includes(o.id))
: diff.filter((o) =>
["create", "update", "proxy_violation", "delete"].includes(o.kind),
);
const jobId = createSyncJob(db, zoneId);
updateSyncJob(db, jobId, { status: "running" });
try {
for (const op of selected) {
if (op.kind === "orphan" || op.kind === "noop") continue;
if (!op.desired) continue;
const payload = {
type: String(op.desired.type),
name: String(op.desired.name),
content: String(op.desired.content),
ttl: Number(op.desired.ttl ?? zone.defaultTtl),
proxied: false,
};
if (op.kind === "create") {
const created = await cf.createDnsRecord(zone.cfZoneId, payload);
if (op.entityType === "alias" && op.entityId) {
updateAlias(db, op.entityId, {
syncStatus: "ok",
cfRecordId: created.id ?? null,
lastError: null,
});
} else if (op.entityType === "node_a" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfARecordId: created.id ?? null,
lastError: null,
});
} else if (op.entityType === "node_aaaa" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfAaaaRecordId: created.id ?? null,
lastError: null,
});
}
} else if (
(op.kind === "update" || op.kind === "proxy_violation") &&
op.observed?.id
) {
await cf.updateDnsRecord(
zone.cfZoneId,
String(op.observed.id),
payload,
);
if (op.entityType === "alias" && op.entityId) {
updateAlias(db, op.entityId, {
syncStatus: "ok",
cfRecordId: String(op.observed.id),
lastError: null,
});
} else if (op.entityType === "node_a" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfARecordId: String(op.observed.id),
lastError: null,
});
} else if (op.entityType === "node_aaaa" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfAaaaRecordId: String(op.observed.id),
lastError: null,
});
}
} else if (op.kind === "delete" && op.observed?.id) {
await cf.deleteDnsRecord(zone.cfZoneId, String(op.observed.id));
}
addSyncEvent(db, jobId, {
kind: `applied_${op.kind}`,
recordName: op.recordName,
recordType: op.recordType,
});
}
updateSyncJob(db, jobId, {
status: "done",
diffJson: JSON.stringify(selected),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
updateZone(db, zoneId, {
lastSyncAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
return getSyncJob(db, jobId);
} catch (err) {
updateSyncJob(db, jobId, {
status: "failed",
error: err instanceof Error ? err.message : String(err),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
throw err;
}
}
export async function retargetAlias(
db: Db,
cf: CloudflareClient,
aliasId: string,
targetNodeId: string,
) {
const alias = updateAlias(db, aliasId, { targetNodeId });
const zone = getZone(db, alias.zoneId);
const target = getNode(db, targetNodeId);
if (cf.isConfigured && zone.cfZoneId) {
const payload = {
type: "CNAME",
name: alias.name,
content: target.hostname,
ttl: zone.defaultTtl,
proxied: false,
};
if (alias.cfRecordId) {
await cf.patchDnsRecord(zone.cfZoneId, alias.cfRecordId, {
content: target.hostname,
proxied: false,
});
updateAlias(db, aliasId, { syncStatus: "ok", lastError: null });
} else {
const created = await cf.createDnsRecord(zone.cfZoneId, payload);
updateAlias(db, aliasId, {
syncStatus: "ok",
cfRecordId: created.id ?? null,
lastError: null,
});
}
} else {
updateAlias(db, aliasId, { syncStatus: "pending" });
}
return getAlias(db, aliasId);
}
export function exportBindZone(db: Db, zoneId: string): string {
const zone = getZone(db, zoneId);
const nodeList = listNodes(db, { zoneId });
const aliasList = listAliases(db, { zoneId });
const lines: string[] = [
`;; CDNManager export — ${zone.name}`,
`;; TTL default ${zone.defaultTtl}; all records DNS-only (proxied:false)`,
"",
";; Canonical A/AAAA",
];
for (const node of nodeList) {
const v4 = node.addresses.find((a) => a.family === "v4");
const v6 = node.addresses.find((a) => a.family === "v6");
if (v4) {
lines.push(
`${node.hostname}. ${zone.defaultTtl} IN A ${v4.ip} ; cf_tags=cf-proxied:false`,
);
}
if (v6) {
lines.push(
`${node.hostname}. ${zone.defaultTtl} IN AAAA ${v6.ip} ; cf_tags=cf-proxied:false`,
);
}
}
lines.push("", ";; Service CNAME aliases");
for (const alias of aliasList) {
lines.push(
`${alias.name}. ${zone.defaultTtl} IN CNAME ${alias.targetHostname}. ; purpose=${alias.purpose}`,
);
}
lines.push("");
return lines.join("\n");
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import type { FastifyInstance } from "fastify";
describe("fleet api", () => {
let app: FastifyInstance;
let authHeader: { authorization: string };
beforeAll(async () => {
app = await buildApp({
config: { ...loadConfig(), staticDir: null, authRequired: false },
memory: true,
});
const login = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(login.statusCode).toBe(200);
const token = login.json().token as string;
authHeader = { authorization: `Bearer ${token}` };
});
afterAll(async () => {
await app.close();
});
it("lists seeded locations", async () => {
const res = await app.inject({
method: "GET",
url: "/api/v1/locations",
headers: authHeader,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.length).toBeGreaterThanOrEqual(5);
expect(body.some((l: { code: string }) => l.code === "msk")).toBe(true);
});
it("creates zone, node, alias and exports bind", async () => {
const zoneRes = await app.inject({
method: "POST",
url: "/api/v1/zones",
headers: authHeader,
payload: { name: "rtnt.top" },
});
expect(zoneRes.statusCode).toBe(200);
const zone = zoneRes.json();
const locs = (
await app.inject({
method: "GET",
url: "/api/v1/locations",
headers: authHeader,
})
).json();
const msk = locs.find((l: { code: string }) => l.code === "msk");
const nodeRes = await app.inject({
method: "POST",
url: "/api/v1/nodes",
headers: authHeader,
payload: {
zoneId: zone.id,
locationId: msk.id,
role: "hub",
indexNum: 1,
ipv4: "94.142.140.141",
},
});
expect(nodeRes.statusCode).toBe(200);
const node = nodeRes.json();
expect(node.hostname).toBe("msk-hub01.rtnt.top");
const aliasRes = await app.inject({
method: "POST",
url: "/api/v1/aliases",
headers: authHeader,
payload: {
zoneId: zone.id,
name: "msk.rtnt.top",
purpose: "geo",
mode: "primary",
targetNodeId: node.id,
},
});
expect(aliasRes.statusCode).toBe(200);
const bind = await app.inject({
method: "GET",
url: `/api/v1/zones/${zone.id}/export/bind`,
headers: authHeader,
});
expect(bind.statusCode).toBe(200);
expect(bind.json().content).toContain("msk-hub01.rtnt.top");
expect(bind.json().content).toContain("msk.rtnt.top");
const stats = await app.inject({
method: "GET",
url: "/api/v1/dashboard/stats",
headers: authHeader,
});
expect(stats.statusCode).toBe(200);
expect(stats.json().nodes).toBe(1);
expect(stats.json().aliases).toBe(1);
const topo = await app.inject({
method: "GET",
url: "/api/v1/topology",
headers: authHeader,
});
expect(topo.statusCode).toBe(200);
expect(topo.json().nodes).toHaveLength(1);
});
});
+12 -1
View File
@@ -1,5 +1,12 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { LayoutDashboardIcon, SettingsIcon } from 'lucide-react'
import {
CloudIcon,
LayoutDashboardIcon,
Link2Icon,
MapIcon,
ServerIcon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import {
@@ -17,6 +24,10 @@ import {
const mainNav = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
{ to: '/nodes', label: 'Ноды', icon: ServerIcon, exact: false },
{ to: '/aliases', label: 'Алиасы', icon: Link2Icon, exact: false },
{ to: '/topology', label: 'Топология', icon: MapIcon, exact: false },
{ to: '/zones', label: 'Зоны / Sync', icon: CloudIcon, exact: false },
{
to: '/settings/appearance',
label: 'Настройки',
@@ -0,0 +1,100 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cdnmanager/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
*/
export interface AttentionQueueColumn {
id: string
title: string
icon: LucideIcon
iconClassName?: string
count: number
countVariant?:
| 'destructive'
| 'warning'
| 'secondary'
| 'destructive-light'
| 'warning-light'
emptyTitle: string
emptyDescription: string
emptyAction?: ReactNode
children: ReactNode
}
interface AttentionQueueProps {
columns: AttentionQueueColumn[]
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
return (
<div
className={cn(
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
className,
)}
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
aria-hidden="true"
>
<Icon />
</IconTile>
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
{column.count > 0 ? (
<Badge
size="sm"
variant={column.countVariant ?? 'secondary'}
className="tabular-nums"
>
{column.count}
</Badge>
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<div className="flex min-h-28 items-center justify-center py-4">
<EmptyState
icon={Icon}
title={column.emptyTitle}
description={column.emptyDescription}
action={column.emptyAction}
centered={false}
stackedIcon={false}
/>
</div>
) : (
column.children
)}
</FramePanel>
</Frame>
)
})}
</div>
)
}
@@ -0,0 +1,183 @@
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
import { StatusBadge } from '@/components/status-badge'
import { EmptyState } from '@/components/empty-state'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cdnmanager/ui/components/chart'
import { cn } from '@cdnmanager/ui/lib/utils'
const syncChartConfig = {
count: { label: 'Записи' },
ok: { label: 'OK', color: 'var(--success)' },
drift: { label: 'Drift', color: 'var(--warning)' },
missing: { label: 'Нет в CF', color: 'var(--warning)' },
pending: { label: 'Ожидает', color: 'var(--info)' },
error: { label: 'Ошибка', color: 'var(--destructive)' },
} satisfies ChartConfig
const locationChartConfig = {
count: { label: 'Ноды', color: 'var(--chart-1)' },
} satisfies ChartConfig
function statusColor(status: string) {
return (
(syncChartConfig as Record<string, { color?: string }>)[status]?.color ??
'var(--chart-1)'
)
}
interface SyncStatusChartProps {
data: { status: string; count: number }[]
}
/** Pie of sync statuses — DNA from CFDM CertStatusChart */
export function SyncStatusChart({ data }: SyncStatusChartProps) {
const total = data.reduce((sum, entry) => sum + entry.count, 0)
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Статусы синхронизации</FrameTitle>
<FrameDescription>Ноды и алиасы по sync_status</FrameDescription>
</FrameHeader>
<FramePanel fit className="flex min-h-52 flex-col">
{data.length === 0 || total === 0 ? (
<div className="flex min-h-52 w-full flex-1 items-center justify-center py-6">
<EmptyState
title="Нет данных"
description="Добавьте ноды или выполните sync зоны"
centered={false}
/>
</div>
) : (
<div className="grid w-full gap-6 @md:grid-cols-[9rem_minmax(0,1fr)] @md:items-center">
<div className="relative mx-auto size-36 shrink-0">
<ChartContainer
config={syncChartConfig}
className="aspect-square size-36"
initialDimension={{ width: 144, height: 144 }}
>
<PieChart margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
<ChartTooltip
content={<ChartTooltipContent nameKey="status" hideLabel />}
/>
<Pie
data={data}
dataKey="count"
nameKey="status"
innerRadius={40}
outerRadius={64}
strokeWidth={2}
>
{data.map((entry) => (
<Cell
key={entry.status}
fill={statusColor(entry.status)}
/>
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-semibold tabular-nums">{total}</span>
<span className="text-muted-foreground text-xs">всего</span>
</div>
</div>
<ul className="flex flex-col gap-2">
{data.map((entry) => (
<li
key={entry.status}
className="flex items-center justify-between gap-2"
>
<StatusBadge status={entry.status} />
<span className="text-muted-foreground text-sm tabular-nums">
{entry.count}
</span>
</li>
))}
</ul>
</div>
)}
</FramePanel>
</Frame>
)
}
interface NodesByLocationChartProps {
data: { name: string; count: number }[]
}
/** Bar chart of nodes by location — DNA from CFDM GroupDomainsChart */
export function NodesByLocationChart({ data }: NodesByLocationChartProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Ноды по локациям</FrameTitle>
<FrameDescription>Распределение флота по кодам городов</FrameDescription>
</FrameHeader>
<FramePanel fit className="flex min-h-52 flex-col">
{data.length === 0 ? (
<div className="flex min-h-52 w-full flex-1 items-center justify-center py-6">
<EmptyState
title="Нет нод"
description="Создайте канонический хост в локации"
centered={false}
/>
</div>
) : (
<div className="flex w-full flex-col gap-4">
<ChartContainer
config={locationChartConfig}
className="aspect-auto h-52 w-full min-h-52"
initialDimension={{ width: 480, height: 208 }}
>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
interval={0}
height={40}
tickFormatter={(value: string) =>
value.length > 12 ? `${value.slice(0, 11)}` : value
}
/>
<ChartTooltip content={<ChartTooltipContent nameKey="count" />} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
<ul className="grid gap-2 @sm:grid-cols-2">
{data.map((entry) => (
<li
key={entry.name}
className={cn(
'bg-muted/40 flex items-center justify-between gap-2 rounded-lg border px-3 py-2',
)}
>
<span className="truncate text-sm font-medium">{entry.name}</span>
<span className="text-muted-foreground shrink-0 text-sm tabular-nums">
{entry.count}
</span>
</li>
))}
</ul>
</div>
)}
</FramePanel>
</Frame>
)
}
@@ -15,3 +15,11 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export {
AttentionQueue,
type AttentionQueueColumn,
} from './attention-queue'
export {
SyncStatusChart,
NodesByLocationChart,
} from './dashboard-analytics'
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon } from 'lucide-react'
import { CloudIcon, PaletteIcon } from 'lucide-react'
import { useIsMobile } from '@cdnmanager/ui/hooks/use-mobile'
import { cn } from '@cdnmanager/ui/lib/utils'
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'cloudflare',
to: '/settings/cloudflare',
label: 'Cloudflare',
icon: <CloudIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
@@ -31,7 +37,7 @@ interface SettingsShellProps {
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид приложения',
description = 'Внешний вид и параметры Cloudflare DNS',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
+6
View File
@@ -11,7 +11,10 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
synced: 'success-light',
ok: 'success-light',
up: 'success-light',
pending: 'secondary',
pending_push: 'secondary',
drift: 'warning-light',
missing: 'warning-light',
warning: 'warning-light',
degraded: 'warning-light',
conflict: 'destructive-light',
@@ -36,7 +39,10 @@ const DOT_COLOR: Record<string, string> = {
const STATUS_LABELS: Record<string, string> = {
active: 'Активен',
synced: 'Синхронизировано',
pending: 'Ожидает',
pending_push: 'Ожидает отправки',
drift: 'Drift',
missing: 'Нет в CF',
conflict: 'Конфликт',
error: 'Ошибка',
ok: 'OK',
+5
View File
@@ -5,10 +5,15 @@ export interface BreadcrumbCrumb {
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/nodes': 'Ноды',
'/aliases': 'Алиасы',
'/topology': 'Топология',
'/zones': 'Зоны / Sync',
}
const SETTINGS_SECTIONS: Record<string, string> = {
'/settings/appearance': 'Внешний вид',
'/settings/cloudflare': 'Cloudflare',
}
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
+1
View File
@@ -0,0 +1 @@
export { queryClient } from './queryClient'
+192
View File
@@ -0,0 +1,192 @@
import { queryOptions } from '@tanstack/react-query'
import type {
Alias,
AliasCreate,
AliasPatch,
AliasRetarget,
BindExport,
DashboardStats,
Location,
Node,
NodeCreate,
NodePatch,
Topology,
Zone,
ZoneCreate,
ZonePatch,
SyncJob,
CfZone,
} from '@cdnmanager/shared'
import { api } from '@/lib/api-client'
export const fleetKeys = {
all: ['fleet'] as const,
locations: () => [...fleetKeys.all, 'locations'] as const,
zones: () => [...fleetKeys.all, 'zones'] as const,
cfZones: () => [...fleetKeys.all, 'cf-zones'] as const,
nodes: (filters?: Record<string, string | undefined>) =>
[...fleetKeys.all, 'nodes', filters ?? {}] as const,
node: (id: string) => [...fleetKeys.all, 'node', id] as const,
aliases: (filters?: Record<string, string | undefined>) =>
[...fleetKeys.all, 'aliases', filters ?? {}] as const,
alias: (id: string) => [...fleetKeys.all, 'alias', id] as const,
dashboard: () => [...fleetKeys.all, 'dashboard'] as const,
topology: (zoneId?: string) =>
[...fleetKeys.all, 'topology', zoneId ?? 'all'] as const,
syncJobs: (zoneId: string) =>
[...fleetKeys.all, 'sync-jobs', zoneId] as const,
bindExport: (zoneId: string) =>
[...fleetKeys.all, 'bind', zoneId] as const,
namingPreview: (params: Record<string, string>) =>
[...fleetKeys.all, 'naming', params] as const,
}
function qs(filters?: Record<string, string | undefined>) {
if (!filters) return ''
const p = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v) p.set(k, v)
}
const s = p.toString()
return s ? `?${s}` : ''
}
export const locationsQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.locations(),
queryFn: () => api.get<Location[]>('/api/v1/locations'),
})
export const zonesQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.zones(),
queryFn: () => api.get<Zone[]>('/api/v1/zones'),
})
export const cfZonesQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.cfZones(),
queryFn: () => api.get<CfZone[]>('/api/v1/cloudflare/zones'),
retry: false,
})
export const nodesQueryOptions = (filters?: Record<string, string | undefined>) =>
queryOptions({
queryKey: fleetKeys.nodes(filters),
queryFn: () => api.get<Node[]>(`/api/v1/nodes${qs(filters)}`),
})
export const aliasesQueryOptions = (
filters?: Record<string, string | undefined>,
) =>
queryOptions({
queryKey: fleetKeys.aliases(filters),
queryFn: () => api.get<Alias[]>(`/api/v1/aliases${qs(filters)}`),
})
export const dashboardStatsQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.dashboard(),
queryFn: () => api.get<DashboardStats>('/api/v1/dashboard/stats'),
staleTime: 15_000,
})
export const topologyQueryOptions = (zoneId?: string) =>
queryOptions({
queryKey: fleetKeys.topology(zoneId),
queryFn: () =>
api.get<Topology>(
`/api/v1/topology${zoneId ? `?zoneId=${encodeURIComponent(zoneId)}` : ''}`,
),
})
export const syncJobsQueryOptions = (zoneId: string) =>
queryOptions({
queryKey: fleetKeys.syncJobs(zoneId),
queryFn: () => api.get<SyncJob[]>(`/api/v1/zones/${zoneId}/sync-jobs`),
enabled: Boolean(zoneId),
})
export const bindExportQueryOptions = (zoneId: string) =>
queryOptions({
queryKey: fleetKeys.bindExport(zoneId),
queryFn: () =>
api.get<BindExport>(`/api/v1/zones/${zoneId}/export/bind`),
enabled: Boolean(zoneId),
})
export async function createZone(body: ZoneCreate) {
return api.post<Zone>('/api/v1/zones', body)
}
export async function patchZone(id: string, body: ZonePatch) {
return api.patch<Zone>(`/api/v1/zones/${id}`, body)
}
export async function removeZone(id: string) {
return api.delete(`/api/v1/zones/${id}`)
}
export async function syncZone(id: string) {
return api.post<SyncJob>(`/api/v1/zones/${id}/sync`, {})
}
export async function applyZone(id: string, opIds?: string[]) {
return api.post<SyncJob>(`/api/v1/zones/${id}/apply`, { opIds })
}
export async function createNode(body: NodeCreate) {
return api.post<Node>('/api/v1/nodes', body)
}
export async function patchNode(id: string, body: NodePatch) {
return api.patch<Node>(`/api/v1/nodes/${id}`, body)
}
export async function removeNode(id: string) {
return api.delete(`/api/v1/nodes/${id}`)
}
export async function createAlias(body: AliasCreate) {
return api.post<Alias>('/api/v1/aliases', body)
}
export async function patchAlias(id: string, body: AliasPatch) {
return api.patch<Alias>(`/api/v1/aliases/${id}`, body)
}
export async function retargetAliasApi(id: string, body: AliasRetarget) {
return api.post<Alias>(`/api/v1/aliases/${id}/retarget`, body)
}
export async function removeAlias(id: string) {
return api.delete(`/api/v1/aliases/${id}`)
}
export async function ignoreOrphan(
zoneId: string,
recordName: string,
recordType: string,
) {
return api.post(`/api/v1/zones/${zoneId}/orphans/ignore`, {
recordName,
recordType,
})
}
export async function previewHostname(params: {
zoneId: string
locationId: string
role: string
indexNum?: number
providerTag?: string
}) {
const p = new URLSearchParams({
zoneId: params.zoneId,
locationId: params.locationId,
role: params.role,
indexNum: String(params.indexNum ?? 1),
})
if (params.providerTag) p.set('providerTag', params.providerTag)
return api.get<{ hostname: string }>(`/api/v1/naming/preview?${p}`)
}
+1
View File
@@ -1 +1,2 @@
export * from '@/queries/app-switcher'
export * from '@/queries/fleet'
+111 -1
View File
@@ -13,8 +13,13 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthZonesRouteImport } from './routes/_auth/zones'
import { Route as AuthTopologyRouteImport } from './routes/_auth/topology'
import { Route as AuthNodesRouteImport } from './routes/_auth/nodes'
import { Route as AuthAliasesRouteImport } from './routes/_auth/aliases'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthSettingsCloudflareRouteImport } from './routes/_auth/settings/cloudflare'
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
const LoginRoute = LoginRouteImport.update({
@@ -36,6 +41,26 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthZonesRoute = AuthZonesRouteImport.update({
id: '/zones',
path: '/zones',
getParentRoute: () => AuthRoute,
} as any)
const AuthTopologyRoute = AuthTopologyRouteImport.update({
id: '/topology',
path: '/topology',
getParentRoute: () => AuthRoute,
} as any)
const AuthNodesRoute = AuthNodesRouteImport.update({
id: '/nodes',
path: '/nodes',
getParentRoute: () => AuthRoute,
} as any)
const AuthAliasesRoute = AuthAliasesRouteImport.update({
id: '/aliases',
path: '/aliases',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
id: '/settings',
path: '/settings',
@@ -46,6 +71,11 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsCloudflareRoute = AuthSettingsCloudflareRouteImport.update({
id: '/cloudflare',
path: '/cloudflare',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
id: '/appearance',
path: '/appearance',
@@ -56,15 +86,25 @@ export interface FileRoutesByFullPath {
'/': typeof AuthIndexRoute
'/login': typeof LoginRoute
'/settings': typeof AuthSettingsRouteRouteWithChildren
'/aliases': typeof AuthAliasesRoute
'/nodes': typeof AuthNodesRoute
'/topology': typeof AuthTopologyRoute
'/zones': typeof AuthZonesRoute
'/auth/callback': typeof AuthCallbackRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/aliases': typeof AuthAliasesRoute
'/nodes': typeof AuthNodesRoute
'/topology': typeof AuthTopologyRoute
'/zones': typeof AuthZonesRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/settings': typeof AuthSettingsIndexRoute
}
export interface FileRoutesById {
@@ -72,9 +112,14 @@ export interface FileRoutesById {
'/_auth': typeof AuthRouteWithChildren
'/login': typeof LoginRoute
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
'/_auth/aliases': typeof AuthAliasesRoute
'/_auth/nodes': typeof AuthNodesRoute
'/_auth/topology': typeof AuthTopologyRoute
'/_auth/zones': typeof AuthZonesRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
'/_auth/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRouteTypes {
@@ -83,19 +128,39 @@ export interface FileRouteTypes {
| '/'
| '/login'
| '/settings'
| '/aliases'
| '/nodes'
| '/topology'
| '/zones'
| '/auth/callback'
| '/settings/appearance'
| '/settings/cloudflare'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/auth/callback' | '/' | '/settings/appearance' | '/settings'
to:
| '/login'
| '/aliases'
| '/nodes'
| '/topology'
| '/zones'
| '/auth/callback'
| '/'
| '/settings/appearance'
| '/settings/cloudflare'
| '/settings'
id:
| '__root__'
| '/_auth'
| '/login'
| '/_auth/settings'
| '/_auth/aliases'
| '/_auth/nodes'
| '/_auth/topology'
| '/_auth/zones'
| '/auth/callback'
| '/_auth/'
| '/_auth/settings/appearance'
| '/_auth/settings/cloudflare'
| '/_auth/settings/'
fileRoutesById: FileRoutesById
}
@@ -135,6 +200,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/zones': {
id: '/_auth/zones'
path: '/zones'
fullPath: '/zones'
preLoaderRoute: typeof AuthZonesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/topology': {
id: '/_auth/topology'
path: '/topology'
fullPath: '/topology'
preLoaderRoute: typeof AuthTopologyRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/nodes': {
id: '/_auth/nodes'
path: '/nodes'
fullPath: '/nodes'
preLoaderRoute: typeof AuthNodesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/aliases': {
id: '/_auth/aliases'
path: '/aliases'
fullPath: '/aliases'
preLoaderRoute: typeof AuthAliasesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
@@ -149,6 +242,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/cloudflare': {
id: '/_auth/settings/cloudflare'
path: '/cloudflare'
fullPath: '/settings/cloudflare'
preLoaderRoute: typeof AuthSettingsCloudflareRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/appearance': {
id: '/_auth/settings/appearance'
path: '/appearance'
@@ -161,11 +261,13 @@ declare module '@tanstack/react-router' {
interface AuthSettingsRouteRouteChildren {
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
AuthSettingsCloudflareRoute: typeof AuthSettingsCloudflareRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
}
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
AuthSettingsCloudflareRoute: AuthSettingsCloudflareRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
}
@@ -174,11 +276,19 @@ const AuthSettingsRouteRouteWithChildren =
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthAliasesRoute: typeof AuthAliasesRoute
AuthNodesRoute: typeof AuthNodesRoute
AuthTopologyRoute: typeof AuthTopologyRoute
AuthZonesRoute: typeof AuthZonesRoute
AuthIndexRoute: typeof AuthIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
AuthAliasesRoute: AuthAliasesRoute,
AuthNodesRoute: AuthNodesRoute,
AuthTopologyRoute: AuthTopologyRoute,
AuthZonesRoute: AuthZonesRoute,
AuthIndexRoute: AuthIndexRoute,
}
+430
View File
@@ -0,0 +1,430 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { Link2Icon, PlusIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Alias, AliasMode, AliasPurpose } from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { FormSheet } from '@/components/form-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@cdnmanager/ui/components/button'
import { Input } from '@cdnmanager/ui/components/input'
import { Label } from '@cdnmanager/ui/components/label'
import { SelectField } from '@/components/select-field'
import { queryClient } from '@/lib/query-client'
import {
aliasesQueryOptions,
createAlias,
nodesQueryOptions,
patchAlias,
removeAlias,
retargetAliasApi,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/aliases')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(aliasesQueryOptions()),
queryClient.ensureQueryData(nodesQueryOptions()),
queryClient.ensureQueryData(zonesQueryOptions()),
]),
component: AliasesPage,
})
const PURPOSES: { value: AliasPurpose; label: string }[] = [
{ value: 'geo', label: 'geo' },
{ value: 'ix', label: 'ix' },
{ value: 'backup', label: 'backup' },
{ value: 'admin', label: 'admin' },
{ value: 'custom', label: 'custom' },
]
const MODES: { value: AliasMode; label: string }[] = [
{ value: 'primary', label: 'primary' },
{ value: 'pair', label: 'pair' },
]
const formSchema = z.object({
zoneId: z.string().min(1, 'Выберите зону'),
name: z.string().min(1, 'Укажите имя'),
purpose: z.enum(['geo', 'ix', 'backup', 'admin', 'custom']),
mode: z.enum(['primary', 'pair']),
targetNodeId: z.string().min(1, 'Выберите ноду'),
})
type FormValues = z.infer<typeof formSchema>
function AliasesPage() {
const qc = useQueryClient()
const { data: aliases = [], isLoading, isError, error, refetch } = useQuery(
aliasesQueryOptions(),
)
const { data: nodes = [] } = useQuery(nodesQueryOptions())
const { data: zones = [] } = useQuery(zonesQueryOptions())
const [filters, setFilters] = useState<Filter[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<Alias | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [retargetId, setRetargetId] = useState<string | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
zoneId: '',
name: '',
purpose: 'geo',
mode: 'primary',
targetNodeId: '',
},
})
const retargetForm = useForm<{ targetNodeId: string }>({
resolver: zodResolver(z.object({ targetNodeId: z.string().min(1) })),
defaultValues: { targetNodeId: '' },
})
const saveMutation = useMutation({
mutationFn: async (values: FormValues) => {
if (editing) {
return patchAlias(editing.id, {
name: values.name,
purpose: values.purpose,
mode: values.mode,
targetNodeId: values.targetNodeId,
})
}
return createAlias(values)
},
onSuccess: () => {
toast.success(editing ? 'Алиас обновлён' : 'Алиас создан')
setSheetOpen(false)
setEditing(null)
form.reset()
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => removeAlias(id),
onSuccess: () => {
toast.success('Алиас удалён')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const retargetMutation = useMutation({
mutationFn: ({ id, targetNodeId }: { id: string; targetNodeId: string }) =>
retargetAliasApi(id, { targetNodeId }),
onSuccess: () => {
toast.success('Цель алиаса изменена')
setRetargetId(null)
retargetForm.reset({ targetNodeId: '' })
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'q',
label: 'Поиск',
type: 'text',
placeholder: 'имя / hostname',
},
{
key: 'purpose',
label: 'Purpose',
type: 'select',
options: PURPOSES.map((p) => ({ value: p.value, label: p.label })),
},
{
key: 'syncStatus',
label: 'Sync',
type: 'select',
options: [
{ value: 'ok', label: 'ok' },
{ value: 'drift', label: 'drift' },
{ value: 'missing', label: 'missing' },
{ value: 'pending', label: 'pending' },
{ value: 'error', label: 'error' },
],
},
],
[],
)
const nodeOptions = nodes.map((n) => ({
value: n.id,
label: n.hostname,
}))
const columns: ColumnDef<Alias, unknown>[] = [
{
accessorKey: 'name',
header: 'Имя',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Link2Icon className="text-muted-foreground size-4 shrink-0" />
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: 'purpose',
header: 'Purpose',
},
{
accessorKey: 'mode',
header: 'Mode',
},
{
accessorKey: 'targetHostname',
header: 'Target',
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.targetHostname ?? '—'}
</span>
),
},
{
accessorKey: 'syncStatus',
header: 'Sync',
cell: ({ row }) => <StatusBadge status={row.original.syncStatus} />,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const a = row.original
setEditing(a)
form.reset({
zoneId: a.zoneId,
name: a.name,
purpose: a.purpose,
mode: a.mode,
targetNodeId: a.targetNodeId,
})
setSheetOpen(true)
}}
>
Изменить
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setRetargetId(row.original.id)
retargetForm.reset({
targetNodeId: row.original.targetNodeId,
})
}}
>
Retarget
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(row.original.id)}
>
Удалить
</Button>
</div>
),
},
]
return (
<PageShell>
<PageHeader
title="Алиасы"
description="CNAME на канонические ноды (geo / ix / backup)"
actions={
<Button
size="sm"
onClick={() => {
setEditing(null)
form.reset({
zoneId: zones[0]?.id ?? '',
name: '',
purpose: 'geo',
mode: 'primary',
targetNodeId: nodes[0]?.id ?? '',
})
setSheetOpen(true)
}}
disabled={zones.length === 0 || nodes.length === 0}
>
<PlusIcon data-icon="inline-start" />
Добавить алиас
</Button>
}
/>
<ResourcePage
title="Алиасы"
description="Desired-state CNAME"
hideHeader
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) => {
if (field === 'q') {
return `${item.name} ${item.targetHostname ?? ''}`
}
return (item as Record<string, unknown>)[field]
}}
columns={columns}
data={aliases}
getRowId={(r) => r.id}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
emptyState={{
title: 'Нет алиасов',
description: 'Создайте CNAME, указывающий на ноду флота',
}}
/>
<FormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={editing ? 'Изменить алиас' : 'Новый алиас'}
description="CNAME name → target node hostname"
form={form}
onSubmit={async (v) => {
await saveMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
}
>
<div className="flex flex-col gap-4">
{!editing ? (
<div className="flex flex-col gap-2">
<Label>Зона</Label>
<SelectField
value={form.watch('zoneId')}
onValueChange={(v) => form.setValue('zoneId', v ?? '')}
placeholder="Зона"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label>Имя (FQDN / label)</Label>
<Input {...form.register('name')} placeholder="msk.example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-2">
<Label>Purpose</Label>
<SelectField
value={form.watch('purpose')}
onValueChange={(v) =>
form.setValue('purpose', (v as AliasPurpose) ?? 'geo')
}
options={PURPOSES.map((p) => ({
value: p.value,
label: p.label,
}))}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Mode</Label>
<SelectField
value={form.watch('mode')}
onValueChange={(v) =>
form.setValue('mode', (v as AliasMode) ?? 'primary')
}
options={MODES.map((m) => ({
value: m.value,
label: m.label,
}))}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Target node</Label>
<SelectField
value={form.watch('targetNodeId')}
onValueChange={(v) => form.setValue('targetNodeId', v ?? '')}
placeholder="Нода"
options={nodeOptions}
/>
</div>
</div>
</FormSheet>
<FormSheet
open={Boolean(retargetId)}
onOpenChange={(o) => {
if (!o) {
setRetargetId(null)
retargetForm.reset({ targetNodeId: '' })
}
}}
title="Переназначить алиас"
description="Выберите новую целевую ноду"
form={retargetForm}
onSubmit={async (v) => {
if (!retargetId) return
await retargetMutation.mutateAsync({
id: retargetId,
targetNodeId: v.targetNodeId,
})
}}
footer={
<Button type="submit" disabled={retargetMutation.isPending}>
{retargetMutation.isPending ? 'Сохранение…' : 'Retarget'}
</Button>
}
>
<div className="flex flex-col gap-2">
<Label>Новая нода</Label>
<SelectField
value={retargetForm.watch('targetNodeId')}
onValueChange={(v) =>
retargetForm.setValue('targetNodeId', v ?? '')
}
placeholder="Нода"
options={nodeOptions}
/>
</div>
</FormSheet>
<ConfirmDialog
open={Boolean(deleteId)}
onOpenChange={(o) => !o && setDeleteId(null)}
title="Удалить алиас?"
description="CNAME будет удалён из desired-state и при следующем apply — из Cloudflare."
confirmLabel="Удалить"
onConfirm={() => deleteId && deleteMutation.mutate(deleteId)}
/>
</PageShell>
)
}
+329 -22
View File
@@ -1,71 +1,378 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LayoutDashboardIcon, SettingsIcon } from 'lucide-react'
import {
ActivityIcon,
AlertTriangleIcon,
CloudIcon,
GlobeIcon,
Link2Icon,
MapIcon,
RefreshCwIcon,
ServerIcon,
} from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import {
AttentionQueue,
NodesByLocationChart,
OpsDashboard,
QuickActionGrid,
SyncStatusChart,
type KpiStatCard,
type QuickActionItem,
} from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cdnmanager/ui/components/item'
import { Button } from '@cdnmanager/ui/components/button'
import { api } from '@/lib/api-client'
import { EmptyState } from '@/components/empty-state'
import { queryClient } from '@/lib/query-client'
import type { AppSettings } from '@cdnmanager/shared'
import {
aliasesQueryOptions,
dashboardStatsQueryOptions,
nodesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(dashboardStatsQueryOptions()),
queryClient.ensureQueryData(nodesQueryOptions()),
queryClient.ensureQueryData(aliasesQueryOptions()),
]),
component: DashboardPage,
})
function DashboardPage() {
const {
data: stats,
isLoading: statsLoading,
} = useQuery(dashboardStatsQueryOptions())
const { data: nodes = [], isLoading: nodesLoading } = useQuery(
nodesQueryOptions(),
)
const { data: aliases = [] } = useQuery(aliasesQueryOptions())
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
queryFn: () => api.get<AppSettings>('/api/v1/settings'),
})
const showQuickActions = appSettings?.showQuickActions !== false
const isLoading = statsLoading || nodesLoading
const locationChartData = useMemo(() => {
const counts = new Map<string, number>()
for (const node of nodes) {
const key = node.locationCode || '—'
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
}, [nodes])
const syncChartData = useMemo(() => {
const counts = new Map<string, number>()
for (const node of nodes) {
counts.set(node.syncStatus, (counts.get(node.syncStatus) ?? 0) + 1)
}
for (const alias of aliases) {
counts.set(alias.syncStatus, (counts.get(alias.syncStatus) ?? 0) + 1)
}
return [...counts.entries()]
.map(([status, count]) => ({ status, count }))
.sort((a, b) => b.count - a.count)
}, [nodes, aliases])
const driftItems = useMemo(
() =>
[
...nodes
.filter((n) => n.syncStatus === 'drift' || n.syncStatus === 'missing')
.map((n) => ({
id: `n-${n.id}`,
label: n.hostname,
status: n.syncStatus,
to: '/nodes' as const,
})),
...aliases
.filter((a) => a.syncStatus === 'drift' || a.syncStatus === 'missing')
.map((a) => ({
id: `a-${a.id}`,
label: a.name,
status: a.syncStatus,
to: '/aliases' as const,
})),
].slice(0, 8),
[nodes, aliases],
)
const proxyItems = useMemo(
() =>
aliases
.filter((a) => a.lastError?.toLowerCase().includes('prox'))
.slice(0, 8)
.map((a) => ({
id: a.id,
label: a.name,
status: a.syncStatus,
})),
[aliases],
)
const orphanHint = stats?.orphans ?? 0
const kpiCards: KpiStatCard[] = [
{
id: 'ready',
label: 'Статус',
value: 'Готов',
icon: <LayoutDashboardIcon aria-hidden />,
id: 'nodes',
label: 'Ноды',
value: stats?.nodes ?? nodes.length,
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info',
to: '/nodes',
},
{
id: 'aliases',
label: 'Алиасы',
value: stats?.aliases ?? aliases.length,
icon: <GlobeIcon aria-hidden />,
iconClassName: 'text-success',
hint: 'Скелет CDN Manager',
to: '/aliases',
},
{
id: 'syncOk',
label: 'Sync OK',
value: stats?.syncOk ?? 0,
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'drift',
label: 'Drift',
value: stats?.drift ?? 0,
variant: (stats?.drift ?? 0) > 0 ? 'warning' : 'default',
icon: <RefreshCwIcon aria-hidden />,
iconClassName: 'text-warning',
to: '/zones',
},
{
id: 'proxyViolations',
label: 'Proxy',
value: stats?.proxyViolations ?? 0,
variant: (stats?.proxyViolations ?? 0) > 0 ? 'destructive' : 'default',
icon: <AlertTriangleIcon aria-hidden />,
iconClassName: 'text-destructive',
to: '/aliases',
},
{
id: 'orphans',
label: 'Orphans',
value: stats?.orphans ?? 0,
variant: (stats?.orphans ?? 0) > 0 ? 'warning' : 'default',
icon: <CloudIcon aria-hidden />,
iconClassName: 'text-warning',
to: '/zones',
},
]
const quickActions: QuickActionItem[] = [
{
id: 'settings',
title: 'Настройки',
description: 'Внешний вид и параметры приложения.',
to: '/settings/appearance',
icon: <SettingsIcon aria-hidden />,
id: 'nodes',
title: 'Ноды',
description: 'Канонические A/AAAA хосты флота.',
to: '/nodes',
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info',
},
{
id: 'aliases',
title: 'Алиасы',
description: 'CNAME geo / ix / backup.',
to: '/aliases',
icon: <Link2Icon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'topology',
title: 'Топология',
description: 'Карта нод и рёбер.',
to: '/topology',
icon: <MapIcon aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'zones',
title: 'Зоны / Sync',
description: 'Синхронизация с Cloudflare.',
to: '/zones',
icon: <CloudIcon aria-hidden />,
iconClassName: 'text-warning',
},
]
return (
<PageShell>
<PageHeader
title="Панель управления"
description="CDN Manager — базовый каркас для разработки"
description="Обзор флота CDN: ноды, алиасы и синхронизация DNS"
/>
<OpsDashboard
isLoading={isLoading}
kpiCards={kpiCards}
afterKpi={
showQuickActions ? <QuickActionGrid actions={quickActions} /> : null
showQuickActions ? (
<QuickActionGrid
actions={quickActions}
description="Частые разделы управления флотом"
/>
) : null
}
charts={
<EmptyState
title="Пока пусто"
description="Доменная логика CDN будет добавлена позже."
<>
<NodesByLocationChart data={locationChartData} />
<SyncStatusChart data={syncChartData} />
</>
}
queueTitle="Требуют внимания"
queueDescription="Drift, proxy-нарушения и orphan-записи в Cloudflare"
queue={
<AttentionQueue
columns={[
{
id: 'drift',
title: 'Drift',
icon: RefreshCwIcon,
iconClassName: 'text-warning [&_svg]:text-current',
count: driftItems.length,
countVariant: 'warning-light',
emptyTitle: 'Нет drift',
emptyDescription: 'Ноды и алиасы совпадают с Cloudflare',
emptyAction: (
<Button variant="outline" size="sm" render={<Link to="/zones" />}>
К зонам
</Button>
),
children: (
<ItemGroup className="gap-2">
{driftItems.map((item) => (
<Item
key={item.id}
variant="outline"
size="sm"
render={<Link to={item.to} />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{item.label}
</ItemTitle>
</ItemContent>
<ItemActions>
<StatusBadge status={item.status} />
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
{
id: 'proxy',
title: 'Proxy',
icon: AlertTriangleIcon,
iconClassName: 'text-destructive [&_svg]:text-current',
count: stats?.proxyViolations ?? proxyItems.length,
countVariant: 'destructive-light',
emptyTitle: 'Нет нарушений',
emptyDescription: 'Proxied lock соблюдён',
emptyAction: (
<Button
variant="outline"
size="sm"
render={<Link to="/aliases" />}
>
К алиасам
</Button>
),
children: (
<ItemGroup className="gap-2">
{proxyItems.length > 0
? proxyItems.map((item) => (
<Item
key={item.id}
variant="outline"
size="sm"
render={<Link to="/aliases" />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{item.label}
</ItemTitle>
</ItemContent>
<ItemActions>
<StatusBadge status={item.status} />
</ItemActions>
</Item>
))
: (
<Item
variant="outline"
size="sm"
render={<Link to="/aliases" />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{(stats?.proxyViolations ?? 0) > 0
? `${stats?.proxyViolations} proxy-нарушений`
: 'См. алиасы'}
</ItemTitle>
</ItemContent>
</Item>
)}
</ItemGroup>
),
},
{
id: 'orphans',
title: 'Orphans',
icon: CloudIcon,
iconClassName: 'text-warning [&_svg]:text-current',
count: orphanHint,
countVariant: 'warning-light',
emptyTitle: 'Нет orphans',
emptyDescription: 'В Cloudflare нет лишних записей',
emptyAction: (
<Button variant="outline" size="sm" render={<Link to="/zones" />}>
К зонам
</Button>
),
children: (
<ItemGroup className="gap-2">
<Item
variant="outline"
size="sm"
render={<Link to="/zones" />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{orphanHint} orphan-записей в CF
</ItemTitle>
</ItemContent>
<ItemActions>
<StatusBadge status="drift" label="orphan" />
</ItemActions>
</Item>
</ItemGroup>
),
},
]}
/>
}
queue={null}
queueTitle="Очередь"
queueDescription="Здесь появятся операционные события"
/>
</PageShell>
)
+483
View File
@@ -0,0 +1,483 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
CloudIcon,
MapPinIcon,
PlusIcon,
RefreshCwIcon,
ServerIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Node, NodeRole } from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { FormSheet } from '@/components/form-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@cdnmanager/ui/components/button'
import { Input } from '@cdnmanager/ui/components/input'
import { Label } from '@cdnmanager/ui/components/label'
import { SelectField } from '@/components/select-field'
import { queryClient } from '@/lib/query-client'
import {
createNode,
locationsQueryOptions,
nodesQueryOptions,
patchNode,
previewHostname,
removeNode,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/nodes')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(nodesQueryOptions()),
queryClient.ensureQueryData(locationsQueryOptions()),
queryClient.ensureQueryData(zonesQueryOptions()),
]),
component: NodesPage,
})
const formSchema = z.object({
zoneId: z.string().min(1, 'Выберите зону'),
locationId: z.string().min(1, 'Выберите локацию'),
role: z.enum(['hub', 'gw', 'edge', 'ix']),
indexNum: z.number().int().min(1).max(99),
ipv4: z.string().min(7),
ipv6: z.string().optional(),
providerTag: z.string().optional(),
notes: z.string().optional(),
hostname: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
const ROLES: { value: NodeRole; label: string }[] = [
{ value: 'hub', label: 'hub' },
{ value: 'gw', label: 'gw' },
{ value: 'edge', label: 'edge' },
{ value: 'ix', label: 'ix' },
]
function NodesPage() {
const qc = useQueryClient()
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
nodesQueryOptions(),
)
const { data: locations = [] } = useQuery(locationsQueryOptions())
const { data: zones = [] } = useQuery(zonesQueryOptions())
const [filters, setFilters] = useState<Filter[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<Node | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [preview, setPreview] = useState('')
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
zoneId: '',
locationId: '',
role: 'gw',
indexNum: 1,
ipv4: '',
ipv6: '',
providerTag: '',
notes: '',
hostname: '',
},
})
const watchZone = form.watch('zoneId')
const watchLoc = form.watch('locationId')
const watchRole = form.watch('role')
const watchIndex = form.watch('indexNum')
const watchProvider = form.watch('providerTag')
async function refreshPreview() {
if (!watchZone || !watchLoc || !watchRole) return
try {
const res = await previewHostname({
zoneId: watchZone,
locationId: watchLoc,
role: watchRole,
indexNum: Number(watchIndex) || 1,
providerTag: watchProvider || undefined,
})
setPreview(res.hostname)
} catch {
setPreview('')
}
}
const saveMutation = useMutation({
mutationFn: async (values: FormValues) => {
if (editing) {
return patchNode(editing.id, {
locationId: values.locationId,
role: values.role,
indexNum: values.indexNum,
ipv4: values.ipv4,
ipv6: values.ipv6 || null,
providerTag: values.providerTag || null,
notes: values.notes || null,
hostname: values.hostname || undefined,
})
}
return createNode({
zoneId: values.zoneId,
locationId: values.locationId,
role: values.role,
indexNum: values.indexNum,
ipv4: values.ipv4,
ipv6: values.ipv6 || null,
providerTag: values.providerTag || null,
notes: values.notes || null,
hostname: values.hostname || undefined,
})
},
onSuccess: () => {
toast.success(editing ? 'Нода обновлена' : 'Нода создана')
setSheetOpen(false)
setEditing(null)
form.reset()
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => removeNode(id),
onSuccess: () => {
toast.success('Нода удалена')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'q',
label: 'Поиск',
type: 'text',
placeholder: 'hostname / provider',
},
{
key: 'locationCode',
label: 'Локация',
type: 'select',
options: locations.map((l) => ({ value: l.code, label: l.code })),
},
{
key: 'role',
label: 'Роль',
type: 'select',
options: ROLES.map((r) => ({ value: r.value, label: r.label })),
},
{
key: 'syncStatus',
label: 'Sync',
type: 'select',
options: [
{ value: 'ok', label: 'ok' },
{ value: 'drift', label: 'drift' },
{ value: 'missing', label: 'missing' },
{ value: 'pending', label: 'pending' },
{ value: 'error', label: 'error' },
],
},
],
[locations],
)
const columns: ColumnDef<Node, unknown>[] = [
{
accessorKey: 'hostname',
header: 'FQDN',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
<span className="font-medium">{row.original.hostname}</span>
</div>
),
},
{
accessorKey: 'locationCode',
header: 'Локация',
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
<MapPinIcon className="size-3.5" />
{row.original.locationCode ?? '—'}
</span>
),
},
{
accessorKey: 'role',
header: 'Роль',
},
{
id: 'ip',
header: 'IPv4 / IPv6',
cell: ({ row }) => {
const v4 = row.original.addresses.find((a) => a.family === 'v4')?.ip
const v6 = row.original.addresses.find((a) => a.family === 'v6')?.ip
return (
<div className="flex flex-col gap-0.5 font-mono text-xs tabular-nums">
<span>{v4 ?? '—'}</span>
{v6 ? <span className="text-muted-foreground">{v6}</span> : null}
</div>
)
},
},
{
accessorKey: 'providerTag',
header: 'Provider',
cell: ({ row }) => row.original.providerTag || '—',
},
{
accessorKey: 'syncStatus',
header: 'Sync',
cell: ({ row }) => <StatusBadge status={row.original.syncStatus} />,
},
{
accessorKey: 'aliasCount',
header: 'CNAME→',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.aliasCount ?? 0}</span>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const n = row.original
setEditing(n)
form.reset({
zoneId: n.zoneId,
locationId: n.locationId,
role: n.role as NodeRole,
indexNum: n.indexNum,
ipv4: n.addresses.find((a) => a.family === 'v4')?.ip ?? '',
ipv6: n.addresses.find((a) => a.family === 'v6')?.ip ?? '',
providerTag: n.providerTag ?? '',
notes: n.notes ?? '',
hostname: n.hostname,
})
setPreview(n.hostname)
setSheetOpen(true)
}}
>
Изменить
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(row.original.id)}
>
Удалить
</Button>
</div>
),
},
]
return (
<PageShell>
<PageHeader
title="Ноды"
description="Канонические хосты A/AAAA (железо CHR/VPS)"
actions={
<Button
size="sm"
onClick={() => {
setEditing(null)
form.reset({
zoneId: zones[0]?.id ?? '',
locationId: locations[0]?.id ?? '',
role: 'gw',
indexNum: 1,
ipv4: '',
ipv6: '',
providerTag: '',
notes: '',
hostname: '',
})
setPreview('')
setSheetOpen(true)
void refreshPreview()
}}
disabled={zones.length === 0}
>
<PlusIcon data-icon="inline-start" />
Добавить ноду
</Button>
}
/>
{zones.length === 0 ? (
<p className="text-muted-foreground text-sm">
Сначала добавьте зону на странице{' '}
<Link to="/zones" className="text-primary underline">
Зоны / Sync
</Link>
.
</p>
) : null}
<ResourcePage
title="Инвентарь нод"
description="Desired-state канонических FQDN"
hideHeader
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) => {
if (field === 'q') return `${item.hostname} ${item.providerTag ?? ''}`
if (field === 'locationCode') return item.locationCode
return (item as Record<string, unknown>)[field]
}}
columns={columns}
data={nodes}
getRowId={(r) => r.id}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
emptyState={{
title: 'Нет нод',
description: 'Создайте первую каноническую ноду флота',
}}
/>
<FormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={editing ? 'Изменить ноду' : 'Новая нода'}
description="Имя собирается по шаблону зоны: {loc}-{role}{nn}.{zone}"
form={form}
onSubmit={async (v) => {
await saveMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
}
>
<div className="flex flex-col gap-4">
{!editing ? (
<div className="flex flex-col gap-2">
<Label>Зона</Label>
<SelectField
value={form.watch('zoneId')}
onValueChange={(v) => {
form.setValue('zoneId', v ?? '')
void refreshPreview()
}}
placeholder="Зона"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label>Локация</Label>
<SelectField
value={form.watch('locationId')}
onValueChange={(v) => {
form.setValue('locationId', v ?? '')
void refreshPreview()
}}
placeholder="Локация"
options={locations.map((l) => ({
value: l.id,
label: `${l.code}${l.name}`,
}))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-2">
<Label>Роль</Label>
<SelectField
value={form.watch('role')}
onValueChange={(v) => {
form.setValue('role', (v as NodeRole) ?? 'gw')
void refreshPreview()
}}
options={ROLES.map((r) => ({ value: r.value, label: r.label }))}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Индекс</Label>
<Input
type="number"
min={1}
max={99}
{...form.register('indexNum', { valueAsNumber: true })}
onBlur={() => void refreshPreview()}
/>
</div>
</div>
<div className="bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<CloudIcon className="size-4 shrink-0" />
<span className="text-muted-foreground">Preview:</span>
<code className="font-medium">{preview || '—'}</code>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => void refreshPreview()}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2">
<Label>IPv4</Label>
<Input {...form.register('ipv4')} placeholder="198.51.100.10" />
</div>
<div className="flex flex-col gap-2">
<Label>IPv6 (опц.)</Label>
<Input {...form.register('ipv6')} placeholder="2001:db8::10" />
</div>
<div className="flex flex-col gap-2">
<Label>Provider tag</Label>
<Input {...form.register('providerTag')} placeholder="ih / vv" />
</div>
<div className="flex flex-col gap-2">
<Label>Заметки</Label>
<Input {...form.register('notes')} />
</div>
</div>
</FormSheet>
<ConfirmDialog
open={Boolean(deleteId)}
onOpenChange={(o) => !o && setDeleteId(null)}
title="Удалить ноду?"
description="Алиасы, указывающие на ноду, должны быть удалены или переназначены заранее."
confirmLabel="Удалить"
onConfirm={() => deleteId && deleteMutation.mutate(deleteId)}
/>
</PageShell>
)
}
@@ -0,0 +1,129 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CloudIcon } from 'lucide-react'
import type { AppSettings, AppSettingsPatch } from '@cdnmanager/shared'
import { api } from '@/lib/api-client'
import { SettingRow } from '@/components/setting-row'
import { StatusBadge } from '@/components/status-badge'
import { Switch } from '@cdnmanager/ui/components/switch'
import { Input } from '@cdnmanager/ui/components/input'
import { FieldGroup } from '@cdnmanager/ui/components/field'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/settings/cloudflare')({
component: CloudflareSettingsPage,
})
function CloudflareSettingsPage() {
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<AppSettings>('/api/v1/settings'),
})
const patchMut = useMutation({
mutationFn: (patch: AppSettingsPatch) =>
api.patch<AppSettings>('/api/v1/settings', patch),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки Cloudflare сохранены')
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const configured = data?.cloudflareConfigured === true
return (
<div className="flex w-full flex-col gap-4">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<CloudIcon className="size-4" aria-hidden />
Cloudflare
</FrameTitle>
<FrameDescription>
Параметры DNS sync и naming template
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
<SettingRow
title="API token"
description="CLOUDFLARE_API_TOKEN задаётся только через env сервера API — в UI не хранится."
>
<StatusBadge
status={configured ? 'ok' : 'missing'}
label={configured ? 'Настроен' : 'Не задан'}
/>
</SettingRow>
<SettingRow
title="Default TTL"
description="TTL по умолчанию для A/AAAA и CNAME (6086400)."
labelFor="default-ttl"
>
<Input
id="default-ttl"
type="number"
min={60}
max={86400}
className="w-32"
disabled={isLoading || patchMut.isPending}
defaultValue={data?.defaultTtl ?? 300}
key={data?.defaultTtl ?? 'ttl'}
onBlur={(e) => {
const next = Number(e.target.value)
if (!Number.isFinite(next) || next === data?.defaultTtl) return
patchMut.mutate({ defaultTtl: next })
}}
/>
</SettingRow>
<SettingRow
title="Naming template"
description="Шаблон hostname: {loc}-{role}{nn}.{zone}"
labelFor="naming-template"
stacked
>
<Input
id="naming-template"
className="w-full max-w-md font-mono text-sm"
disabled={isLoading || patchMut.isPending}
defaultValue={data?.namingTemplate ?? ''}
key={data?.namingTemplate ?? 'tpl'}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === data?.namingTemplate) return
patchMut.mutate({ namingTemplate: next })
}}
/>
</SettingRow>
<SettingRow
title="Proxied lock"
description="Запретить orange-cloud (proxied=true) для записей флота."
last
>
<Switch
checked={data?.proxiedLock !== false}
disabled={isLoading || patchMut.isPending}
onCheckedChange={(checked) =>
patchMut.mutate({ proxiedLock: checked })
}
aria-label="Proxied lock"
/>
</SettingRow>
</FieldGroup>
</FramePanel>
</Frame>
</div>
)
}
+23 -2
View File
@@ -1,10 +1,31 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsShell } from '@/components/reui-kit'
import { CloudIcon, PaletteIcon } from 'lucide-react'
import { SettingsShell, type SettingsTabConfig } from '@/components/reui-kit'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsLayout,
})
const SETTINGS_TABS: SettingsTabConfig[] = [
{
id: 'appearance',
to: '/settings/appearance',
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'cloudflare',
to: '/settings/cloudflare',
label: 'Cloudflare',
icon: <CloudIcon className="size-4" aria-hidden="true" />,
},
]
function SettingsLayout() {
return <SettingsShell />
return (
<SettingsShell
description="Внешний вид и параметры Cloudflare DNS"
tabs={SETTINGS_TABS}
/>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link2Icon, MapIcon, ServerIcon } from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { SelectField } from '@/components/select-field'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Label } from '@cdnmanager/ui/components/label'
import { queryClient } from '@/lib/query-client'
import {
topologyQueryOptions,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/topology')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(topologyQueryOptions()),
queryClient.ensureQueryData(zonesQueryOptions()),
]),
component: TopologyPage,
})
function TopologyPage() {
const [zoneId, setZoneId] = useState<string | undefined>(undefined)
const { data: zones = [] } = useQuery(zonesQueryOptions())
const { data, isLoading, isError, refetch } = useQuery(
topologyQueryOptions(zoneId),
)
const nodes = data?.nodes ?? []
const edges = data?.edges ?? []
const byLocation = useMemo(() => {
const map = new Map<string, typeof nodes>()
for (const node of nodes) {
const key = node.locationCode || '—'
const list = map.get(key) ?? []
list.push(node)
map.set(key, list)
}
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b))
}, [nodes])
return (
<PageShell>
<PageHeader
title="Топология"
description="Ноды по локациям и CNAME-рёбра алиасов"
actions={
<div className="flex min-w-48 flex-col gap-1.5">
<Label className="sr-only">Зона</Label>
<SelectField
value={zoneId ?? null}
onValueChange={(v) => setZoneId(v ?? undefined)}
placeholder="Все зоны"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
/>
</div>
}
/>
{isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : isError ? (
<EmptyState
title="Ошибка загрузки"
description="Не удалось получить топологию"
action={
<button
type="button"
className="text-primary text-sm underline"
onClick={() => refetch()}
>
Повторить
</button>
}
/>
) : nodes.length === 0 ? (
<EmptyState
icon={MapIcon}
title="Нет нод"
description="Добавьте ноды, чтобы увидеть топологию флота"
/>
) : (
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{byLocation.map(([code, locNodes]) => (
<Frame key={code} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<MapIcon className="size-4" aria-hidden />
{code}
</FrameTitle>
<FrameDescription>
{locNodes.length}{' '}
{locNodes.length === 1 ? 'нода' : 'нод'}
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
{locNodes.map((node) => (
<div
key={node.id}
className="flex items-start gap-3 rounded-lg border px-3 py-2"
>
<IconTile
variant="elevated"
className="size-10.5 shrink-0 text-success [&_svg]:text-current"
aria-hidden
>
<ServerIcon />
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{node.hostname}
</span>
<Badge size="sm" variant="secondary">
{node.role}
</Badge>
<StatusBadge status={node.syncStatus} />
</div>
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{node.ipv4 ?? '—'}
</span>
</div>
</div>
))}
</FramePanel>
</Frame>
))}
</div>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<Link2Icon className="size-4" aria-hidden />
CNAME edges
</FrameTitle>
<FrameDescription>
Алиас целевой hostname ({edges.length})
</FrameDescription>
</FrameHeader>
<FramePanel>
{edges.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет алиасов</p>
) : (
<ul className="flex flex-col gap-2">
{edges.map((edge) => (
<li
key={edge.id}
className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<Badge size="sm" variant="outline">
{edge.purpose}
</Badge>
<span className="font-medium">{edge.aliasName}</span>
<span className="text-muted-foreground"></span>
<span className="font-mono text-xs">
{edge.toHostname}
</span>
</li>
))}
</ul>
)}
</FramePanel>
</Frame>
</div>
)}
</PageShell>
)
}
+497
View File
@@ -0,0 +1,497 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import {
CloudIcon,
DownloadIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
UploadIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Zone } from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { FormSheet } from '@/components/form-sheet'
import { StatusBadge } from '@/components/status-badge'
import { EmptyState } from '@/components/empty-state'
import { SelectField } from '@/components/select-field'
import { Button } from '@cdnmanager/ui/components/button'
import { Input } from '@cdnmanager/ui/components/input'
import { Label } from '@cdnmanager/ui/components/label'
import { Textarea } from '@cdnmanager/ui/components/textarea'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@cdnmanager/ui/components/sheet'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from '@cdnmanager/ui/components/item'
import { formatRelative } from '@/lib/format'
import { queryClient } from '@/lib/query-client'
import {
applyZone,
bindExportQueryOptions,
cfZonesQueryOptions,
createZone,
patchZone,
syncJobsQueryOptions,
syncZone,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/zones')({
loader: () => queryClient.ensureQueryData(zonesQueryOptions()),
component: ZonesPage,
})
const createSchema = z.object({
name: z.string().min(1, 'Укажите имя зоны'),
cfZoneId: z.string().optional(),
})
type CreateValues = z.infer<typeof createSchema>
const editSchema = z.object({
cfZoneId: z.string().optional(),
})
type EditValues = z.infer<typeof editSchema>
function ZonesPage() {
const qc = useQueryClient()
const { data: zones = [], isLoading, isError, refetch } = useQuery(
zonesQueryOptions(),
)
const { data: cfZones, isError: cfError } = useQuery({
...cfZonesQueryOptions(),
enabled: true,
})
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<Zone | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [bindZoneId, setBindZoneId] = useState<string | null>(null)
const createForm = useForm<CreateValues>({
resolver: zodResolver(createSchema),
defaultValues: { name: '', cfZoneId: '' },
})
const editForm = useForm<EditValues>({
resolver: zodResolver(editSchema),
defaultValues: { cfZoneId: '' },
})
const activeZoneId = selectedId ?? zones[0]?.id ?? ''
const { data: syncJobs = [] } = useQuery(syncJobsQueryOptions(activeZoneId))
const { data: bindExport, isFetching: bindLoading } = useQuery({
...bindExportQueryOptions(bindZoneId ?? ''),
enabled: Boolean(bindZoneId),
})
const latestJob = syncJobs[0]
const driftCount = useMemo(() => {
if (!latestJob?.diff) return 0
return latestJob.diff.filter(
(op) =>
op.kind === 'update' ||
op.kind === 'create' ||
op.kind === 'delete' ||
op.kind === 'orphan' ||
op.kind === 'proxy_violation',
).length
}, [latestJob])
const createMutation = useMutation({
mutationFn: (values: CreateValues) =>
createZone({
name: values.name,
role: 'routing',
cfZoneId: values.cfZoneId || null,
}),
onSuccess: () => {
toast.success('Зона создана')
setCreateOpen(false)
createForm.reset()
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const editMutation = useMutation({
mutationFn: (values: EditValues) => {
if (!editing) throw new Error('Нет зоны')
return patchZone(editing.id, {
cfZoneId: values.cfZoneId || null,
})
},
onSuccess: () => {
toast.success('Зона обновлена')
setEditing(null)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const syncMutation = useMutation({
mutationFn: (id: string) => syncZone(id),
onSuccess: (_, id) => {
toast.success('Sync запущен')
setSelectedId(id)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const applyMutation = useMutation({
mutationFn: (id: string) => applyZone(id),
onSuccess: (_, id) => {
toast.success('Apply запущен')
setSelectedId(id)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const cfOptions =
cfZones?.map((z) => ({ value: z.id, label: `${z.name} (${z.id})` })) ?? []
return (
<PageShell>
<PageHeader
title="Зоны / Sync"
description="Cloudflare DNS zones и desired-state синхронизация"
actions={
<Button
size="sm"
onClick={() => {
createForm.reset({ name: '', cfZoneId: '' })
setCreateOpen(true)
}}
>
<PlusIcon data-icon="inline-start" />
Добавить зону
</Button>
}
/>
{isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : isError ? (
<EmptyState
title="Не удалось загрузить зоны"
description="Проверьте API и повторите"
action={
<Button variant="outline" size="sm" onClick={() => refetch()}>
Повторить
</Button>
}
/>
) : zones.length === 0 ? (
<EmptyState
title="Нет зон"
description="Добавьте зону Cloudflare для управления DNS"
/>
) : (
<div className="flex flex-col gap-4">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Зоны</FrameTitle>
<FrameDescription>
Sync сравнивает desired-state с Cloudflare; Apply пушит diff
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ItemGroup className="gap-0 p-2">
{zones.map((zone) => {
const isActive = zone.id === activeZoneId
return (
<Item
key={zone.id}
variant="outline"
size="sm"
className={
isActive
? 'border-primary/40 bg-muted/40'
: undefined
}
onClick={() => setSelectedId(zone.id)}
>
<IconTile
variant="elevated"
className="size-10.5 text-info [&_svg]:text-current"
aria-hidden
>
<CloudIcon />
</IconTile>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{zone.name}
</ItemTitle>
<ItemDescription className="flex flex-wrap items-center gap-2 text-xs">
<span>
Sync:{' '}
{zone.lastSyncAt
? formatRelative(zone.lastSyncAt)
: 'никогда'}
</span>
{zone.cfZoneId ? (
<Badge size="sm" variant="secondary">
CF
</Badge>
) : (
<Badge size="sm" variant="warning-light">
нет cfZoneId
</Badge>
)}
{isActive && driftCount > 0 ? (
<Badge size="sm" variant="warning-light">
drift {driftCount}
</Badge>
) : null}
</ItemDescription>
</ItemContent>
<ItemActions className="flex flex-wrap gap-1">
<Button
variant="outline"
size="sm"
disabled={syncMutation.isPending}
onClick={(e) => {
e.stopPropagation()
syncMutation.mutate(zone.id)
}}
>
<RefreshCwIcon className="size-3.5" />
Sync
</Button>
<Button
variant="outline"
size="sm"
disabled={applyMutation.isPending}
onClick={(e) => {
e.stopPropagation()
applyMutation.mutate(zone.id)
}}
>
<UploadIcon className="size-3.5" />
Apply
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setBindZoneId(zone.id)
}}
>
<DownloadIcon className="size-3.5" />
BIND
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setEditing(zone)
editForm.reset({
cfZoneId: zone.cfZoneId ?? '',
})
}}
>
<PencilIcon className="size-3.5" />
</Button>
</ItemActions>
</Item>
)
})}
</ItemGroup>
</FramePanel>
</Frame>
{activeZoneId ? (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Последние sync jobs</FrameTitle>
<FrameDescription>
Зона:{' '}
{zones.find((z) => z.id === activeZoneId)?.name ?? activeZoneId}
</FrameDescription>
</FrameHeader>
<FramePanel>
{syncJobs.length === 0 ? (
<p className="text-muted-foreground text-sm">
Ещё не было sync для этой зоны
</p>
) : (
<ul className="flex flex-col gap-2">
{syncJobs.slice(0, 8).map((job) => (
<li
key={job.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<StatusBadge status={job.status} />
<span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(job.createdAt)}
</span>
</div>
<span className="text-muted-foreground text-xs">
{job.diff?.length
? `${job.diff.length} ops`
: job.error || '—'}
</span>
</li>
))}
</ul>
)}
</FramePanel>
</Frame>
) : null}
</div>
)}
<FormSheet
open={createOpen}
onOpenChange={setCreateOpen}
title="Новая зона"
description="Имя зоны DNS; CF Zone ID — опционально"
form={createForm}
onSubmit={async (v) => {
await createMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Имя зоны</Label>
<Input {...createForm.register('name')} placeholder="example.com" />
</div>
<div className="flex flex-col gap-2">
<Label>Cloudflare Zone ID</Label>
{!cfError && cfOptions.length > 0 ? (
<SelectField
value={createForm.watch('cfZoneId') || null}
onValueChange={(v) =>
createForm.setValue('cfZoneId', v ?? '')
}
placeholder="Выберите из CF"
options={cfOptions}
/>
) : (
<Input
{...createForm.register('cfZoneId')}
placeholder="опционально"
/>
)}
{cfError ? (
<p className="text-muted-foreground text-xs">
CF API недоступен введите Zone ID вручную (токен в env)
</p>
) : null}
</div>
</div>
</FormSheet>
<FormSheet
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
title="Изменить зону"
description={editing?.name}
form={editForm}
onSubmit={async (v) => {
await editMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={editMutation.isPending}>
{editMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
}
>
<div className="flex flex-col gap-2">
<Label>Cloudflare Zone ID</Label>
{!cfError && cfOptions.length > 0 ? (
<SelectField
value={editForm.watch('cfZoneId') || null}
onValueChange={(v) => editForm.setValue('cfZoneId', v ?? '')}
placeholder="Выберите из CF"
options={cfOptions}
/>
) : (
<Input
{...editForm.register('cfZoneId')}
placeholder="cf zone id"
/>
)}
</div>
</FormSheet>
<Sheet
open={Boolean(bindZoneId)}
onOpenChange={(o) => !o && setBindZoneId(null)}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-lg">
<SheetHeader>
<SheetTitle>Export BIND</SheetTitle>
<SheetDescription>
Текстовый snapshot desired-state для зоны
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
{bindLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : (
<>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
if (!bindExport?.content) return
await navigator.clipboard.writeText(bindExport.content)
toast.success('Скопировано')
}}
>
Копировать
</Button>
</div>
<Textarea
readOnly
className="min-h-80 font-mono text-xs"
value={bindExport?.content ?? ''}
/>
</>
)}
</div>
</SheetContent>
</Sheet>
</PageShell>
)
}