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
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:
Vendored
+1037
-8
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(":");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { queryClient } from './queryClient'
|
||||
@@ -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 +1,2 @@
|
||||
export * from '@/queries/app-switcher'
|
||||
export * from '@/queries/fleet'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 (60–86400)."
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user