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:
@@ -25,6 +25,9 @@ ADMIN_PASSWORD_HASH=
|
||||
# Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored)
|
||||
REUI_LICENSE_KEY=
|
||||
|
||||
# Cloudflare — Zone DNS Edit + Zone Read
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
|
||||
# Server
|
||||
SERVER_PORT=8081
|
||||
STATIC_DIR=
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# CDN Manager
|
||||
|
||||
Self-hosted CDN management (каркас). Стек как у CFDM: pnpm monorepo, Vite + React + TanStack + shadcn/ReUI, Fastify + Drizzle + SQLite.
|
||||
Self-hosted панель **desired-state DNS** для флота CHR/VPS: канонические ноды (A/AAAA) + сервисные CNAME (failover / geo / ix), синхронизация с Cloudflare DNS-only.
|
||||
|
||||
## Vs CFDM
|
||||
|
||||
| CDNManager | Cloudflare Domain Manager |
|
||||
|------------|---------------------------|
|
||||
| Флот нод + алиасы + drift/orphan | Домены, сервисы, LB, health-checks, сертификаты |
|
||||
| Роутинг-зона (напр. `rtnt.top`) | Мультидоменный ops |
|
||||
| Retarget CNAME как основной UX | Service bindings / HC |
|
||||
|
||||
App id в auth-portal: **`cdn`**.
|
||||
|
||||
## Стек
|
||||
|
||||
@@ -9,28 +19,53 @@ Self-hosted CDN management (каркас). Стек как у CFDM: pnpm monorep
|
||||
- `apps/api` — Fastify + Drizzle + SQLite
|
||||
- Packages: `@cdnmanager/ui`, `@cdnmanager/shared`, `@cdnmanager/db`
|
||||
|
||||
## Доменная модель
|
||||
|
||||
- **Location** — справочник кодов (`msk`, `fra`, `ams`, `hel`, `par`)
|
||||
- **Node** — канонический FQDN + A/AAAA (`msk-hub01.rtnt.top`)
|
||||
- **Alias** — CNAME → node (`msk.rtnt.top`, `ix-eu.rtnt.top`)
|
||||
- **Zone** — Cloudflare zone + naming template / TTL
|
||||
- Managed-записи всегда **`proxied: false`** (туннели WG/GRE)
|
||||
|
||||
Нейминг: шаблон `{loc}-{role}{nn}.{zone}` (см. документ по CHR DNS).
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @cdnmanager/shared build
|
||||
pnpm --filter @cdnmanager/db build
|
||||
pnpm --filter @cdnmanager/api dev
|
||||
pnpm --filter web dev
|
||||
pnpm --filter @cdnmanager/api dev # :8081
|
||||
pnpm --filter web dev # :5176
|
||||
pnpm --filter web build
|
||||
```
|
||||
|
||||
Локально: API `http://localhost:8081`, web `http://localhost:5176`.
|
||||
Env: `CLOUDFLARE_API_TOKEN` (Zone DNS Edit + Zone Read). Локально `AUTH_REQUIRED=false`, логин `admin` / `admin`.
|
||||
|
||||
## ReUI PRO
|
||||
## UI (ReUI PRO + DNA CFDM)
|
||||
|
||||
Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`.
|
||||
License: `REUI_LICENSE_KEY` в `apps/web/.env.local`.
|
||||
Surface: **frame**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||
|
||||
## Auth
|
||||
| Экран | Kit / pattern |
|
||||
|-------|----------------|
|
||||
| Dashboard | `OpsDashboard` + `KpiStatGrid` + `QuickActionGrid` + charts + `AttentionQueue` (как CFDM) |
|
||||
| Ноды / Алиасы | `ResourcePage` |
|
||||
| Топология | Frame grid + IconTile |
|
||||
| Зоны / Sync | Frame Item rows + sync/apply/BIND export |
|
||||
| Settings | `SettingsShell` (appearance + cloudflare) |
|
||||
|
||||
App id в auth-portal: **`cdn`**. Локально `AUTH_REQUIRED=false`, логин `admin` / `admin`.
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [settings-16](https://reui.io/preview/base/settings-16) · [app-shell-12](https://reui.io/preview/base/app-shell-12).
|
||||
|
||||
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. License: `REUI_LICENSE_KEY` в `apps/web/.env.local`.
|
||||
|
||||
## API (кратко)
|
||||
|
||||
```
|
||||
GET/POST /api/v1/zones · POST .../sync · POST .../apply · GET .../export/bind
|
||||
GET/POST /api/v1/nodes · GET/POST /api/v1/aliases · POST .../retarget
|
||||
GET /api/v1/locations · GET /api/v1/dashboard/stats · GET /api/v1/topology
|
||||
GET /api/v1/cloudflare/zones
|
||||
```
|
||||
|
||||
## CI / Docker
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CDN Manager
|
||||
|
||||
Каркас приложения для управления CDN. Monorepo на базе стека Cloudflare Domain Manager (без CF/domain-логики).
|
||||
Панель desired-state DNS для флота CHR/VPS: канонические ноды (A/AAAA) + сервисные CNAME, sync с Cloudflare (DNS-only). Дополняет CFDM, не заменяет его.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
@@ -14,14 +14,20 @@ pnpm --filter web dev # :5176
|
||||
|
||||
Локальный логин: `admin` / `admin` (`AUTH_REQUIRED=false`).
|
||||
|
||||
```env
|
||||
CLOUDFLARE_API_TOKEN= # Zone DNS Edit + Zone Read
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
apps/web # Vite SPA
|
||||
apps/api # Fastify API
|
||||
apps/web # Vite SPA — nodes / aliases / topology / zones / dashboard
|
||||
apps/api # Fastify — fleet + Cloudflare DNS client
|
||||
packages/ui # @cdnmanager/ui — shadcn
|
||||
packages/shared # @cdnmanager/shared — Zod
|
||||
packages/shared # @cdnmanager/shared — Zod contracts
|
||||
packages/db # @cdnmanager/db — Drizzle + SQLite
|
||||
```
|
||||
|
||||
Подробнее: [AGENTS.md](AGENTS.md), [docs/releasing.md](docs/releasing.md), [docs/ui-design-contract.md](docs/ui-design-contract.md).
|
||||
Экраны: Обзор · Ноды · Алиасы · Топология · Зоны/Sync · Настройки.
|
||||
|
||||
Подробнее: [AGENTS.md](AGENTS.md), [docs/Home.md](docs/Home.md), [docs/ui-design-contract.md](docs/ui-design-contract.md).
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
# CDN Manager
|
||||
|
||||
Каркас self-hosted CDN Manager.
|
||||
Self-hosted панель управления DNS флота (ноды A/AAAA + сервисные CNAME) через Cloudflare.
|
||||
|
||||
- App id (auth-portal): `cdn`
|
||||
- Стек: pnpm monorepo, Vite/React/TanStack/ReUI, Fastify/Drizzle/SQLite
|
||||
- Образы: `cdnmanager` / `cdn-manager`
|
||||
- Модель: zones → nodes / aliases → sync desired↔Cloudflare (DNS-only)
|
||||
- UI DNA дашборда: как CFDM (`OpsDashboard`, `AttentionQueue`, hybrid KPI)
|
||||
- Не замена CFDM: там домены/сервисы/LB; здесь флот CHR и failover CNAME
|
||||
|
||||
См. [README](../README.md) и [AGENTS.md](../AGENTS.md).
|
||||
|
||||
Vendored
+2790
-1
File diff suppressed because it is too large
Load Diff
Vendored
+565
-7
@@ -1,14 +1,126 @@
|
||||
// src/schema.ts
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
var appSettings = sqliteTable("app_settings", {
|
||||
id: text("id").primaryKey(),
|
||||
show_quick_actions: integer("show_quick_actions", { mode: "boolean" }).notNull().default(true),
|
||||
default_ttl: integer("default_ttl").notNull().default(300),
|
||||
naming_template: text("naming_template").notNull().default("{loc}-{role}{nn}.{zone}"),
|
||||
proxied_lock: integer("proxied_lock", { mode: "boolean" }).notNull().default(true),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
var zones = sqliteTable("zones", {
|
||||
id: text("id").primaryKey(),
|
||||
cf_zone_id: text("cf_zone_id"),
|
||||
name: text("name").notNull().unique(),
|
||||
role: text("role").notNull().default("routing"),
|
||||
naming_template: text("naming_template").notNull().default("{loc}-{role}{nn}.{zone}"),
|
||||
default_ttl: integer("default_ttl").notNull().default(300),
|
||||
last_sync_at: text("last_sync_at"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
var locations = sqliteTable("locations", {
|
||||
id: text("id").primaryKey(),
|
||||
code: text("code").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
country: text("country"),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
var nodes = sqliteTable(
|
||||
"nodes",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
|
||||
location_id: text("location_id").notNull().references(() => locations.id),
|
||||
hostname: text("hostname").notNull(),
|
||||
role: text("role").notNull(),
|
||||
index_num: integer("index_num").notNull().default(1),
|
||||
provider_tag: text("provider_tag"),
|
||||
notes: text("notes"),
|
||||
sync_status: text("sync_status").notNull().default("pending"),
|
||||
cf_a_record_id: text("cf_a_record_id"),
|
||||
cf_aaaa_record_id: text("cf_aaaa_record_id"),
|
||||
last_error: text("last_error"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
},
|
||||
(t) => [uniqueIndex("nodes_zone_hostname").on(t.zone_id, t.hostname)]
|
||||
);
|
||||
var nodeAddresses = sqliteTable(
|
||||
"node_addresses",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
node_id: text("node_id").notNull().references(() => nodes.id, { onDelete: "cascade" }),
|
||||
family: text("family").notNull(),
|
||||
ip: text("ip").notNull()
|
||||
},
|
||||
(t) => [uniqueIndex("node_addresses_node_family").on(t.node_id, t.family)]
|
||||
);
|
||||
var aliases = sqliteTable(
|
||||
"aliases",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
purpose: text("purpose").notNull().default("geo"),
|
||||
mode: text("mode").notNull().default("primary"),
|
||||
target_node_id: text("target_node_id").notNull().references(() => nodes.id, { onDelete: "restrict" }),
|
||||
sync_status: text("sync_status").notNull().default("pending"),
|
||||
cf_record_id: text("cf_record_id"),
|
||||
last_error: text("last_error"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
},
|
||||
(t) => [uniqueIndex("aliases_zone_name").on(t.zone_id, t.name)]
|
||||
);
|
||||
var syncJobs = sqliteTable("sync_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
|
||||
status: text("status").notNull().default("pending"),
|
||||
diff_json: text("diff_json"),
|
||||
error: text("error"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
finished_at: text("finished_at")
|
||||
});
|
||||
var syncEvents = sqliteTable("sync_events", {
|
||||
id: text("id").primaryKey(),
|
||||
job_id: text("job_id").notNull().references(() => syncJobs.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull(),
|
||||
record_name: text("record_name"),
|
||||
record_type: text("record_type"),
|
||||
detail: text("detail"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
var ignoredOrphans = sqliteTable(
|
||||
"ignored_orphans",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
|
||||
record_name: text("record_name").notNull(),
|
||||
record_type: text("record_type").notNull(),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("ignored_orphans_unique").on(
|
||||
t.zone_id,
|
||||
t.record_name,
|
||||
t.record_type
|
||||
)
|
||||
]
|
||||
);
|
||||
var schema = {
|
||||
appSettings
|
||||
appSettings,
|
||||
zones,
|
||||
locations,
|
||||
nodes,
|
||||
nodeAddresses,
|
||||
aliases,
|
||||
syncJobs,
|
||||
syncEvents,
|
||||
ignoredOrphans
|
||||
};
|
||||
|
||||
// src/client.ts
|
||||
@@ -50,8 +162,8 @@ function runMigrations(sqlite) {
|
||||
for (const file of files) {
|
||||
const applied = sqlite.prepare("SELECT 1 FROM _migrations WHERE name = ?").get(file);
|
||||
if (applied) continue;
|
||||
const sql2 = readFileSync(join(migrationsDir, file), "utf-8");
|
||||
sqlite.exec(sql2);
|
||||
const sql3 = readFileSync(join(migrationsDir, file), "utf-8");
|
||||
sqlite.exec(sql3);
|
||||
sqlite.prepare("INSERT INTO _migrations (name) VALUES (?)").run(file);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +191,10 @@ var SETTINGS_ID = "settings-main";
|
||||
function toDto(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions)
|
||||
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||
defaultTtl: row.default_ttl ?? 300,
|
||||
namingTemplate: row.naming_template ?? "{loc}-{role}{nn}.{zone}",
|
||||
proxiedLock: row.proxied_lock == null ? true : Boolean(row.proxied_lock)
|
||||
};
|
||||
}
|
||||
function ensureRow(db) {
|
||||
@@ -87,7 +202,10 @@ function ensureRow(db) {
|
||||
if (existing) return existing;
|
||||
db.insert(appSettings).values({
|
||||
id: SETTINGS_ID,
|
||||
show_quick_actions: true
|
||||
show_quick_actions: true,
|
||||
default_ttl: 300,
|
||||
naming_template: "{loc}-{role}{nn}.{zone}",
|
||||
proxied_lock: true
|
||||
}).run();
|
||||
return db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get();
|
||||
}
|
||||
@@ -102,19 +220,459 @@ function updateAppSettings(db, patch) {
|
||||
if (patch.showQuickActions !== void 0) {
|
||||
updates.show_quick_actions = patch.showQuickActions;
|
||||
}
|
||||
if (patch.defaultTtl !== void 0) {
|
||||
updates.default_ttl = patch.defaultTtl;
|
||||
}
|
||||
if (patch.namingTemplate !== void 0) {
|
||||
updates.naming_template = patch.namingTemplate;
|
||||
}
|
||||
if (patch.proxiedLock !== void 0) {
|
||||
updates.proxied_lock = patch.proxiedLock;
|
||||
}
|
||||
db.update(appSettings).set(updates).where(eq(appSettings.id, SETTINGS_ID)).run();
|
||||
return getAppSettings(db);
|
||||
}
|
||||
|
||||
// src/fleet-repo.ts
|
||||
import { randomUUID } from "crypto";
|
||||
import { and, asc, count, eq as eq2, sql as sql2 } from "drizzle-orm";
|
||||
function now() {
|
||||
return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
||||
}
|
||||
function id(prefix) {
|
||||
return `${prefix}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
function mapLocation(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
country: row.country,
|
||||
sortOrder: row.sort_order
|
||||
};
|
||||
}
|
||||
function mapZone(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
cfZoneId: row.cf_zone_id,
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
namingTemplate: row.naming_template,
|
||||
defaultTtl: row.default_ttl,
|
||||
lastSyncAt: row.last_sync_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
function listLocations(db) {
|
||||
return db.select().from(locations).orderBy(asc(locations.sort_order), asc(locations.code)).all().map(mapLocation);
|
||||
}
|
||||
function getLocation(db, locationId) {
|
||||
const row = db.select().from(locations).where(eq2(locations.id, locationId)).get();
|
||||
if (!row) throw new NotFoundError(`location ${locationId}`);
|
||||
return mapLocation(row);
|
||||
}
|
||||
function listZones(db) {
|
||||
return db.select().from(zones).orderBy(asc(zones.name)).all().map(mapZone);
|
||||
}
|
||||
function getZone(db, zoneId) {
|
||||
const row = db.select().from(zones).where(eq2(zones.id, zoneId)).get();
|
||||
if (!row) throw new NotFoundError(`zone ${zoneId}`);
|
||||
return mapZone(row);
|
||||
}
|
||||
function createZone(db, input) {
|
||||
const existing = db.select().from(zones).where(eq2(zones.name, input.name)).get();
|
||||
if (existing) throw new ConflictError(`zone ${input.name} already exists`);
|
||||
const zoneId = id("zone");
|
||||
const ts = now();
|
||||
db.insert(zones).values({
|
||||
id: zoneId,
|
||||
name: input.name,
|
||||
cf_zone_id: input.cfZoneId ?? null,
|
||||
role: input.role ?? "routing",
|
||||
naming_template: input.namingTemplate ?? "{loc}-{role}{nn}.{zone}",
|
||||
default_ttl: input.defaultTtl ?? 300,
|
||||
created_at: ts,
|
||||
updated_at: ts
|
||||
}).run();
|
||||
return getZone(db, zoneId);
|
||||
}
|
||||
function updateZone(db, zoneId, patch) {
|
||||
getZone(db, zoneId);
|
||||
const updates = {
|
||||
updated_at: now()
|
||||
};
|
||||
if (patch.name !== void 0) updates.name = patch.name;
|
||||
if (patch.cfZoneId !== void 0) updates.cf_zone_id = patch.cfZoneId;
|
||||
if (patch.role !== void 0) updates.role = patch.role;
|
||||
if (patch.namingTemplate !== void 0)
|
||||
updates.naming_template = patch.namingTemplate;
|
||||
if (patch.defaultTtl !== void 0) updates.default_ttl = patch.defaultTtl;
|
||||
if (patch.lastSyncAt !== void 0) updates.last_sync_at = patch.lastSyncAt;
|
||||
db.update(zones).set(updates).where(eq2(zones.id, zoneId)).run();
|
||||
return getZone(db, zoneId);
|
||||
}
|
||||
function deleteZone(db, zoneId) {
|
||||
getZone(db, zoneId);
|
||||
db.delete(zones).where(eq2(zones.id, zoneId)).run();
|
||||
}
|
||||
function loadAddresses(db, nodeId) {
|
||||
return db.select().from(nodeAddresses).where(eq2(nodeAddresses.node_id, nodeId)).all().map((r) => ({
|
||||
id: r.id,
|
||||
family: r.family,
|
||||
ip: r.ip
|
||||
}));
|
||||
}
|
||||
function mapNode(db, row, loc) {
|
||||
const aliasCount = db.select({ c: count() }).from(aliases).where(eq2(aliases.target_node_id, row.id)).get()?.c;
|
||||
return {
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
locationId: row.location_id,
|
||||
locationCode: loc?.code,
|
||||
locationName: loc?.name,
|
||||
hostname: row.hostname,
|
||||
role: row.role,
|
||||
indexNum: row.index_num,
|
||||
providerTag: row.provider_tag,
|
||||
notes: row.notes,
|
||||
syncStatus: row.sync_status,
|
||||
cfARecordId: row.cf_a_record_id,
|
||||
cfAaaaRecordId: row.cf_aaaa_record_id,
|
||||
lastError: row.last_error,
|
||||
addresses: loadAddresses(db, row.id),
|
||||
aliasCount: Number(aliasCount ?? 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
function listNodes(db, filter = {}) {
|
||||
let rows = db.select().from(nodes).all();
|
||||
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
|
||||
if (filter.locationId)
|
||||
rows = rows.filter((r) => r.location_id === filter.locationId);
|
||||
if (filter.role) rows = rows.filter((r) => r.role === filter.role);
|
||||
if (filter.syncStatus)
|
||||
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
|
||||
if (filter.q) {
|
||||
const q = filter.q.toLowerCase();
|
||||
rows = rows.filter(
|
||||
(r) => r.hostname.toLowerCase().includes(q) || (r.provider_tag ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const locMap = new Map(
|
||||
listLocations(db).map((l) => [l.id, { code: l.code, name: l.name }])
|
||||
);
|
||||
return rows.map((r) => mapNode(db, r, locMap.get(r.location_id))).sort((a, b) => a.hostname.localeCompare(b.hostname));
|
||||
}
|
||||
function getNode(db, nodeId) {
|
||||
const row = db.select().from(nodes).where(eq2(nodes.id, nodeId)).get();
|
||||
if (!row) throw new NotFoundError(`node ${nodeId}`);
|
||||
const loc = getLocation(db, row.location_id);
|
||||
return mapNode(db, row, { code: loc.code, name: loc.name });
|
||||
}
|
||||
function setAddresses(db, nodeId, ipv4, ipv6) {
|
||||
db.delete(nodeAddresses).where(eq2(nodeAddresses.node_id, nodeId)).run();
|
||||
db.insert(nodeAddresses).values({ id: id("addr"), node_id: nodeId, family: "v4", ip: ipv4 }).run();
|
||||
if (ipv6) {
|
||||
db.insert(nodeAddresses).values({ id: id("addr"), node_id: nodeId, family: "v6", ip: ipv6 }).run();
|
||||
}
|
||||
}
|
||||
function createNode(db, input) {
|
||||
getZone(db, input.zoneId);
|
||||
getLocation(db, input.locationId);
|
||||
const dup = db.select().from(nodes).where(
|
||||
and(eq2(nodes.zone_id, input.zoneId), eq2(nodes.hostname, input.hostname))
|
||||
).get();
|
||||
if (dup) throw new ConflictError(`node ${input.hostname} already exists`);
|
||||
const nodeId = id("node");
|
||||
const ts = now();
|
||||
db.insert(nodes).values({
|
||||
id: nodeId,
|
||||
zone_id: input.zoneId,
|
||||
location_id: input.locationId,
|
||||
hostname: input.hostname,
|
||||
role: input.role,
|
||||
index_num: input.indexNum,
|
||||
provider_tag: input.providerTag ?? null,
|
||||
notes: input.notes ?? null,
|
||||
sync_status: "pending",
|
||||
created_at: ts,
|
||||
updated_at: ts
|
||||
}).run();
|
||||
setAddresses(db, nodeId, input.ipv4, input.ipv6);
|
||||
return getNode(db, nodeId);
|
||||
}
|
||||
function updateNode(db, nodeId, patch) {
|
||||
const current = getNode(db, nodeId);
|
||||
if (patch.locationId) getLocation(db, patch.locationId);
|
||||
const updates = {
|
||||
updated_at: now()
|
||||
};
|
||||
if (patch.locationId !== void 0) updates.location_id = patch.locationId;
|
||||
if (patch.hostname !== void 0) updates.hostname = patch.hostname;
|
||||
if (patch.role !== void 0) updates.role = patch.role;
|
||||
if (patch.indexNum !== void 0) updates.index_num = patch.indexNum;
|
||||
if (patch.providerTag !== void 0) updates.provider_tag = patch.providerTag;
|
||||
if (patch.notes !== void 0) updates.notes = patch.notes;
|
||||
if (patch.syncStatus !== void 0) updates.sync_status = patch.syncStatus;
|
||||
if (patch.cfARecordId !== void 0)
|
||||
updates.cf_a_record_id = patch.cfARecordId;
|
||||
if (patch.cfAaaaRecordId !== void 0)
|
||||
updates.cf_aaaa_record_id = patch.cfAaaaRecordId;
|
||||
if (patch.lastError !== void 0) updates.last_error = patch.lastError;
|
||||
db.update(nodes).set(updates).where(eq2(nodes.id, nodeId)).run();
|
||||
if (patch.ipv4 !== void 0) {
|
||||
const v6 = patch.ipv6 !== void 0 ? patch.ipv6 : current.addresses.find((a) => a.family === "v6")?.ip ?? null;
|
||||
setAddresses(db, nodeId, patch.ipv4, v6);
|
||||
} else if (patch.ipv6 !== void 0) {
|
||||
const v4 = current.addresses.find((a) => a.family === "v4")?.ip;
|
||||
if (!v4) throw new ConflictError("node has no IPv4");
|
||||
setAddresses(db, nodeId, v4, patch.ipv6);
|
||||
}
|
||||
return getNode(db, nodeId);
|
||||
}
|
||||
function deleteNode(db, nodeId) {
|
||||
getNode(db, nodeId);
|
||||
const linked = db.select({ c: count() }).from(aliases).where(eq2(aliases.target_node_id, nodeId)).get()?.c;
|
||||
if (Number(linked ?? 0) > 0) {
|
||||
throw new ConflictError("node has aliases; retarget or delete them first");
|
||||
}
|
||||
db.delete(nodes).where(eq2(nodes.id, nodeId)).run();
|
||||
}
|
||||
function mapAlias(row, targetHostname) {
|
||||
return {
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
name: row.name,
|
||||
purpose: row.purpose,
|
||||
mode: row.mode,
|
||||
targetNodeId: row.target_node_id,
|
||||
targetHostname,
|
||||
syncStatus: row.sync_status,
|
||||
cfRecordId: row.cf_record_id,
|
||||
lastError: row.last_error,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
function listAliases(db, filter = {}) {
|
||||
let rows = db.select().from(aliases).all();
|
||||
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
|
||||
if (filter.purpose) rows = rows.filter((r) => r.purpose === filter.purpose);
|
||||
if (filter.syncStatus)
|
||||
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
|
||||
if (filter.q) {
|
||||
const q = filter.q.toLowerCase();
|
||||
rows = rows.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}
|
||||
const nodeHost = new Map(
|
||||
db.select().from(nodes).all().map((n) => [n.id, n.hostname])
|
||||
);
|
||||
return rows.map((r) => mapAlias(r, nodeHost.get(r.target_node_id))).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
function getAlias(db, aliasId) {
|
||||
const row = db.select().from(aliases).where(eq2(aliases.id, aliasId)).get();
|
||||
if (!row) throw new NotFoundError(`alias ${aliasId}`);
|
||||
const target = db.select().from(nodes).where(eq2(nodes.id, row.target_node_id)).get();
|
||||
return mapAlias(row, target?.hostname);
|
||||
}
|
||||
function createAlias(db, input) {
|
||||
getZone(db, input.zoneId);
|
||||
getNode(db, input.targetNodeId);
|
||||
const dup = db.select().from(aliases).where(and(eq2(aliases.zone_id, input.zoneId), eq2(aliases.name, input.name))).get();
|
||||
if (dup) throw new ConflictError(`alias ${input.name} already exists`);
|
||||
const aliasId = id("alias");
|
||||
const ts = now();
|
||||
db.insert(aliases).values({
|
||||
id: aliasId,
|
||||
zone_id: input.zoneId,
|
||||
name: input.name,
|
||||
purpose: input.purpose,
|
||||
mode: input.mode,
|
||||
target_node_id: input.targetNodeId,
|
||||
sync_status: "pending",
|
||||
created_at: ts,
|
||||
updated_at: ts
|
||||
}).run();
|
||||
return getAlias(db, aliasId);
|
||||
}
|
||||
function updateAlias(db, aliasId, patch) {
|
||||
getAlias(db, aliasId);
|
||||
if (patch.targetNodeId) getNode(db, patch.targetNodeId);
|
||||
const updates = {
|
||||
updated_at: now()
|
||||
};
|
||||
if (patch.name !== void 0) updates.name = patch.name;
|
||||
if (patch.purpose !== void 0) updates.purpose = patch.purpose;
|
||||
if (patch.mode !== void 0) updates.mode = patch.mode;
|
||||
if (patch.targetNodeId !== void 0)
|
||||
updates.target_node_id = patch.targetNodeId;
|
||||
if (patch.syncStatus !== void 0) updates.sync_status = patch.syncStatus;
|
||||
if (patch.cfRecordId !== void 0) updates.cf_record_id = patch.cfRecordId;
|
||||
if (patch.lastError !== void 0) updates.last_error = patch.lastError;
|
||||
db.update(aliases).set(updates).where(eq2(aliases.id, aliasId)).run();
|
||||
return getAlias(db, aliasId);
|
||||
}
|
||||
function deleteAlias(db, aliasId) {
|
||||
getAlias(db, aliasId);
|
||||
db.delete(aliases).where(eq2(aliases.id, aliasId)).run();
|
||||
}
|
||||
function createSyncJob(db, zoneId) {
|
||||
getZone(db, zoneId);
|
||||
const jobId = id("sync");
|
||||
db.insert(syncJobs).values({
|
||||
id: jobId,
|
||||
zone_id: zoneId,
|
||||
status: "pending",
|
||||
created_at: now()
|
||||
}).run();
|
||||
return jobId;
|
||||
}
|
||||
function updateSyncJob(db, jobId, patch) {
|
||||
const updates = {};
|
||||
if (patch.status !== void 0) updates.status = patch.status;
|
||||
if (patch.diffJson !== void 0) updates.diff_json = patch.diffJson;
|
||||
if (patch.error !== void 0) updates.error = patch.error;
|
||||
if (patch.finishedAt !== void 0) updates.finished_at = patch.finishedAt;
|
||||
db.update(syncJobs).set(updates).where(eq2(syncJobs.id, jobId)).run();
|
||||
}
|
||||
function getSyncJob(db, jobId) {
|
||||
const row = db.select().from(syncJobs).where(eq2(syncJobs.id, jobId)).get();
|
||||
if (!row) throw new NotFoundError(`sync job ${jobId}`);
|
||||
return {
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
status: row.status,
|
||||
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
|
||||
error: row.error,
|
||||
createdAt: row.created_at,
|
||||
finishedAt: row.finished_at
|
||||
};
|
||||
}
|
||||
function listSyncJobs(db, zoneId) {
|
||||
return db.select().from(syncJobs).where(eq2(syncJobs.zone_id, zoneId)).orderBy(sql2`${syncJobs.created_at} DESC`).all().slice(0, 20).map((row) => ({
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
status: row.status,
|
||||
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
|
||||
error: row.error,
|
||||
createdAt: row.created_at,
|
||||
finishedAt: row.finished_at
|
||||
}));
|
||||
}
|
||||
function addSyncEvent(db, jobId, event) {
|
||||
db.insert(syncEvents).values({
|
||||
id: id("evt"),
|
||||
job_id: jobId,
|
||||
kind: event.kind,
|
||||
record_name: event.recordName ?? null,
|
||||
record_type: event.recordType ?? null,
|
||||
detail: event.detail ?? null,
|
||||
created_at: now()
|
||||
}).run();
|
||||
}
|
||||
function listIgnoredOrphans(db, zoneId) {
|
||||
return db.select().from(ignoredOrphans).where(eq2(ignoredOrphans.zone_id, zoneId)).all().map((r) => ({
|
||||
id: r.id,
|
||||
recordName: r.record_name,
|
||||
recordType: r.record_type
|
||||
}));
|
||||
}
|
||||
function ignoreOrphan(db, zoneId, recordName, recordType) {
|
||||
getZone(db, zoneId);
|
||||
db.insert(ignoredOrphans).values({
|
||||
id: id("ign"),
|
||||
zone_id: zoneId,
|
||||
record_name: recordName,
|
||||
record_type: recordType,
|
||||
created_at: now()
|
||||
}).onConflictDoNothing().run();
|
||||
}
|
||||
function unignoreOrphan(db, zoneId, recordName, recordType) {
|
||||
db.delete(ignoredOrphans).where(
|
||||
and(
|
||||
eq2(ignoredOrphans.zone_id, zoneId),
|
||||
eq2(ignoredOrphans.record_name, recordName),
|
||||
eq2(ignoredOrphans.record_type, recordType)
|
||||
)
|
||||
).run();
|
||||
}
|
||||
function dashboardCounts(db) {
|
||||
const nodeRows = db.select().from(nodes).all();
|
||||
const aliasRows = db.select().from(aliases).all();
|
||||
const zoneRows = db.select().from(zones).all();
|
||||
let syncOk = 0;
|
||||
let drift = 0;
|
||||
let nodesWithoutIp = 0;
|
||||
for (const n of nodeRows) {
|
||||
if (n.sync_status === "ok") syncOk += 1;
|
||||
if (n.sync_status === "drift" || n.sync_status === "missing") drift += 1;
|
||||
const addrs = loadAddresses(db, n.id);
|
||||
if (!addrs.some((a) => a.family === "v4")) nodesWithoutIp += 1;
|
||||
}
|
||||
let brokenAliases = 0;
|
||||
for (const a of aliasRows) {
|
||||
if (a.sync_status === "error" || a.sync_status === "missing")
|
||||
brokenAliases += 1;
|
||||
if (a.sync_status === "drift") drift += 1;
|
||||
if (a.sync_status === "ok") syncOk += 1;
|
||||
}
|
||||
const lastSyncAt = zoneRows.map((z) => z.last_sync_at).filter(Boolean).sort().at(-1) ?? null;
|
||||
return {
|
||||
nodes: nodeRows.length,
|
||||
aliases: aliasRows.length,
|
||||
syncOk,
|
||||
drift,
|
||||
nodesWithoutIp,
|
||||
brokenAliases,
|
||||
lastSyncAt
|
||||
};
|
||||
}
|
||||
export {
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
addSyncEvent,
|
||||
aliases,
|
||||
appSettings,
|
||||
createAlias,
|
||||
createDb,
|
||||
createMemoryDb,
|
||||
createNode,
|
||||
createSyncJob,
|
||||
createZone,
|
||||
dashboardCounts,
|
||||
deleteAlias,
|
||||
deleteNode,
|
||||
deleteZone,
|
||||
getAlias,
|
||||
getAppSettings,
|
||||
getLocation,
|
||||
getNode,
|
||||
getSyncJob,
|
||||
getZone,
|
||||
healthCheck,
|
||||
ignoreOrphan,
|
||||
ignoredOrphans,
|
||||
listAliases,
|
||||
listIgnoredOrphans,
|
||||
listLocations,
|
||||
listNodes,
|
||||
listSyncJobs,
|
||||
listZones,
|
||||
locations,
|
||||
nodeAddresses,
|
||||
nodes,
|
||||
resolveDatabasePath,
|
||||
runMigrations,
|
||||
schema,
|
||||
updateAppSettings
|
||||
syncEvents,
|
||||
syncJobs,
|
||||
unignoreOrphan,
|
||||
updateAlias,
|
||||
updateAppSettings,
|
||||
updateNode,
|
||||
updateSyncJob,
|
||||
updateZone,
|
||||
zones
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
-- Fleet DNS: zones, locations, nodes, aliases, sync
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
cf_zone_id TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'routing',
|
||||
naming_template TEXT NOT NULL DEFAULT '{loc}-{role}{nn}.{zone}',
|
||||
default_ttl INTEGER NOT NULL DEFAULT 300,
|
||||
last_sync_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
country TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
|
||||
location_id TEXT NOT NULL REFERENCES locations(id),
|
||||
hostname TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
index_num INTEGER NOT NULL DEFAULT 1,
|
||||
provider_tag TEXT,
|
||||
notes TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||
cf_a_record_id TEXT,
|
||||
cf_aaaa_record_id TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (zone_id, hostname)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_zone ON nodes(zone_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_location ON nodes(location_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_sync ON nodes(sync_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_addresses (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
family TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
UNIQUE (node_id, family)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aliases (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'geo',
|
||||
mode TEXT NOT NULL DEFAULT 'primary',
|
||||
target_node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||
cf_record_id TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (zone_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_zone ON aliases(zone_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_target ON aliases(target_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_sync ON aliases(sync_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_jobs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
diff_json TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_jobs_zone ON sync_jobs(zone_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_events (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
job_id TEXT NOT NULL REFERENCES sync_jobs(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
record_name TEXT,
|
||||
record_type TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_events_job ON sync_events(job_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ignored_orphans (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
|
||||
record_name TEXT NOT NULL,
|
||||
record_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (zone_id, record_name, record_type)
|
||||
);
|
||||
|
||||
-- Seed locations (IATA / routing codes from naming doc)
|
||||
INSERT OR IGNORE INTO locations (id, code, name, country, sort_order) VALUES
|
||||
('loc-msk', 'msk', 'Москва', 'RU', 10),
|
||||
('loc-fra', 'fra', 'Франкфурт', 'DE', 20),
|
||||
('loc-ams', 'ams', 'Амстердам', 'NL', 30),
|
||||
('loc-hel', 'hel', 'Хельсинки', 'FI', 40),
|
||||
('loc-par', 'par', 'Париж', 'FR', 50);
|
||||
|
||||
-- Extend settings defaults for naming / TTL
|
||||
ALTER TABLE app_settings ADD COLUMN default_ttl INTEGER NOT NULL DEFAULT 300;
|
||||
ALTER TABLE app_settings ADD COLUMN naming_template TEXT NOT NULL DEFAULT '{loc}-{role}{nn}.{zone}';
|
||||
ALTER TABLE app_settings ADD COLUMN proxied_lock INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,734 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, count, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "./client.js";
|
||||
import { NotFoundError, ConflictError } from "./errors.js";
|
||||
import {
|
||||
aliases,
|
||||
ignoredOrphans,
|
||||
locations,
|
||||
nodeAddresses,
|
||||
nodes,
|
||||
syncEvents,
|
||||
syncJobs,
|
||||
zones,
|
||||
} from "./schema.js";
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
}
|
||||
|
||||
function id(prefix: string) {
|
||||
return `${prefix}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export type LocationRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
country: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type ZoneRow = {
|
||||
id: string;
|
||||
cfZoneId: string | null;
|
||||
name: string;
|
||||
role: string;
|
||||
namingTemplate: string;
|
||||
defaultTtl: number;
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type NodeAddressRow = {
|
||||
id: string;
|
||||
family: "v4" | "v6";
|
||||
ip: string;
|
||||
};
|
||||
|
||||
export type NodeRow = {
|
||||
id: string;
|
||||
zoneId: string;
|
||||
locationId: string;
|
||||
locationCode?: string;
|
||||
locationName?: string;
|
||||
hostname: string;
|
||||
role: string;
|
||||
indexNum: number;
|
||||
providerTag: string | null;
|
||||
notes: string | null;
|
||||
syncStatus: string;
|
||||
cfARecordId: string | null;
|
||||
cfAaaaRecordId: string | null;
|
||||
lastError: string | null;
|
||||
addresses: NodeAddressRow[];
|
||||
aliasCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AliasRow = {
|
||||
id: string;
|
||||
zoneId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
mode: string;
|
||||
targetNodeId: string;
|
||||
targetHostname?: string;
|
||||
syncStatus: string;
|
||||
cfRecordId: string | null;
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function mapLocation(row: typeof locations.$inferSelect): LocationRow {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
country: row.country,
|
||||
sortOrder: row.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
function mapZone(row: typeof zones.$inferSelect): ZoneRow {
|
||||
return {
|
||||
id: row.id,
|
||||
cfZoneId: row.cf_zone_id,
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
namingTemplate: row.naming_template,
|
||||
defaultTtl: row.default_ttl,
|
||||
lastSyncAt: row.last_sync_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function listLocations(db: Db): LocationRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(locations)
|
||||
.orderBy(asc(locations.sort_order), asc(locations.code))
|
||||
.all()
|
||||
.map(mapLocation);
|
||||
}
|
||||
|
||||
export function getLocation(db: Db, locationId: string): LocationRow {
|
||||
const row = db
|
||||
.select()
|
||||
.from(locations)
|
||||
.where(eq(locations.id, locationId))
|
||||
.get();
|
||||
if (!row) throw new NotFoundError(`location ${locationId}`);
|
||||
return mapLocation(row);
|
||||
}
|
||||
|
||||
export function listZones(db: Db): ZoneRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(zones)
|
||||
.orderBy(asc(zones.name))
|
||||
.all()
|
||||
.map(mapZone);
|
||||
}
|
||||
|
||||
export function getZone(db: Db, zoneId: string): ZoneRow {
|
||||
const row = db.select().from(zones).where(eq(zones.id, zoneId)).get();
|
||||
if (!row) throw new NotFoundError(`zone ${zoneId}`);
|
||||
return mapZone(row);
|
||||
}
|
||||
|
||||
export function createZone(
|
||||
db: Db,
|
||||
input: {
|
||||
name: string;
|
||||
cfZoneId?: string | null;
|
||||
role?: string;
|
||||
namingTemplate?: string;
|
||||
defaultTtl?: number;
|
||||
},
|
||||
): ZoneRow {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(zones)
|
||||
.where(eq(zones.name, input.name))
|
||||
.get();
|
||||
if (existing) throw new ConflictError(`zone ${input.name} already exists`);
|
||||
|
||||
const zoneId = id("zone");
|
||||
const ts = now();
|
||||
db.insert(zones)
|
||||
.values({
|
||||
id: zoneId,
|
||||
name: input.name,
|
||||
cf_zone_id: input.cfZoneId ?? null,
|
||||
role: input.role ?? "routing",
|
||||
naming_template: input.namingTemplate ?? "{loc}-{role}{nn}.{zone}",
|
||||
default_ttl: input.defaultTtl ?? 300,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
})
|
||||
.run();
|
||||
return getZone(db, zoneId);
|
||||
}
|
||||
|
||||
export function updateZone(
|
||||
db: Db,
|
||||
zoneId: string,
|
||||
patch: {
|
||||
name?: string;
|
||||
cfZoneId?: string | null;
|
||||
role?: string;
|
||||
namingTemplate?: string;
|
||||
defaultTtl?: number;
|
||||
lastSyncAt?: string | null;
|
||||
},
|
||||
): ZoneRow {
|
||||
getZone(db, zoneId);
|
||||
const updates: Partial<typeof zones.$inferInsert> = {
|
||||
updated_at: now(),
|
||||
};
|
||||
if (patch.name !== undefined) updates.name = patch.name;
|
||||
if (patch.cfZoneId !== undefined) updates.cf_zone_id = patch.cfZoneId;
|
||||
if (patch.role !== undefined) updates.role = patch.role;
|
||||
if (patch.namingTemplate !== undefined)
|
||||
updates.naming_template = patch.namingTemplate;
|
||||
if (patch.defaultTtl !== undefined) updates.default_ttl = patch.defaultTtl;
|
||||
if (patch.lastSyncAt !== undefined) updates.last_sync_at = patch.lastSyncAt;
|
||||
db.update(zones).set(updates).where(eq(zones.id, zoneId)).run();
|
||||
return getZone(db, zoneId);
|
||||
}
|
||||
|
||||
export function deleteZone(db: Db, zoneId: string): void {
|
||||
getZone(db, zoneId);
|
||||
db.delete(zones).where(eq(zones.id, zoneId)).run();
|
||||
}
|
||||
|
||||
function loadAddresses(db: Db, nodeId: string): NodeAddressRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(nodeAddresses)
|
||||
.where(eq(nodeAddresses.node_id, nodeId))
|
||||
.all()
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
family: r.family as "v4" | "v6",
|
||||
ip: r.ip,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapNode(
|
||||
db: Db,
|
||||
row: typeof nodes.$inferSelect,
|
||||
loc?: { code: string; name: string },
|
||||
): NodeRow {
|
||||
const aliasCount = db
|
||||
.select({ c: count() })
|
||||
.from(aliases)
|
||||
.where(eq(aliases.target_node_id, row.id))
|
||||
.get()?.c;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
locationId: row.location_id,
|
||||
locationCode: loc?.code,
|
||||
locationName: loc?.name,
|
||||
hostname: row.hostname,
|
||||
role: row.role,
|
||||
indexNum: row.index_num,
|
||||
providerTag: row.provider_tag,
|
||||
notes: row.notes,
|
||||
syncStatus: row.sync_status,
|
||||
cfARecordId: row.cf_a_record_id,
|
||||
cfAaaaRecordId: row.cf_aaaa_record_id,
|
||||
lastError: row.last_error,
|
||||
addresses: loadAddresses(db, row.id),
|
||||
aliasCount: Number(aliasCount ?? 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function listNodes(
|
||||
db: Db,
|
||||
filter: {
|
||||
zoneId?: string;
|
||||
locationId?: string;
|
||||
role?: string;
|
||||
syncStatus?: string;
|
||||
q?: string;
|
||||
} = {},
|
||||
): NodeRow[] {
|
||||
let rows = db.select().from(nodes).all();
|
||||
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
|
||||
if (filter.locationId)
|
||||
rows = rows.filter((r) => r.location_id === filter.locationId);
|
||||
if (filter.role) rows = rows.filter((r) => r.role === filter.role);
|
||||
if (filter.syncStatus)
|
||||
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
|
||||
if (filter.q) {
|
||||
const q = filter.q.toLowerCase();
|
||||
rows = rows.filter(
|
||||
(r) =>
|
||||
r.hostname.toLowerCase().includes(q) ||
|
||||
(r.provider_tag ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
const locMap = new Map(
|
||||
listLocations(db).map((l) => [l.id, { code: l.code, name: l.name }]),
|
||||
);
|
||||
return rows
|
||||
.map((r) => mapNode(db, r, locMap.get(r.location_id)))
|
||||
.sort((a, b) => a.hostname.localeCompare(b.hostname));
|
||||
}
|
||||
|
||||
export function getNode(db: Db, nodeId: string): NodeRow {
|
||||
const row = db.select().from(nodes).where(eq(nodes.id, nodeId)).get();
|
||||
if (!row) throw new NotFoundError(`node ${nodeId}`);
|
||||
const loc = getLocation(db, row.location_id);
|
||||
return mapNode(db, row, { code: loc.code, name: loc.name });
|
||||
}
|
||||
|
||||
function setAddresses(
|
||||
db: Db,
|
||||
nodeId: string,
|
||||
ipv4: string,
|
||||
ipv6?: string | null,
|
||||
) {
|
||||
db.delete(nodeAddresses).where(eq(nodeAddresses.node_id, nodeId)).run();
|
||||
db.insert(nodeAddresses)
|
||||
.values({ id: id("addr"), node_id: nodeId, family: "v4", ip: ipv4 })
|
||||
.run();
|
||||
if (ipv6) {
|
||||
db.insert(nodeAddresses)
|
||||
.values({ id: id("addr"), node_id: nodeId, family: "v6", ip: ipv6 })
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export function createNode(
|
||||
db: Db,
|
||||
input: {
|
||||
zoneId: string;
|
||||
locationId: string;
|
||||
hostname: string;
|
||||
role: string;
|
||||
indexNum: number;
|
||||
providerTag?: string | null;
|
||||
notes?: string | null;
|
||||
ipv4: string;
|
||||
ipv6?: string | null;
|
||||
},
|
||||
): NodeRow {
|
||||
getZone(db, input.zoneId);
|
||||
getLocation(db, input.locationId);
|
||||
const dup = db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.where(
|
||||
and(eq(nodes.zone_id, input.zoneId), eq(nodes.hostname, input.hostname)),
|
||||
)
|
||||
.get();
|
||||
if (dup) throw new ConflictError(`node ${input.hostname} already exists`);
|
||||
|
||||
const nodeId = id("node");
|
||||
const ts = now();
|
||||
db.insert(nodes)
|
||||
.values({
|
||||
id: nodeId,
|
||||
zone_id: input.zoneId,
|
||||
location_id: input.locationId,
|
||||
hostname: input.hostname,
|
||||
role: input.role,
|
||||
index_num: input.indexNum,
|
||||
provider_tag: input.providerTag ?? null,
|
||||
notes: input.notes ?? null,
|
||||
sync_status: "pending",
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
})
|
||||
.run();
|
||||
setAddresses(db, nodeId, input.ipv4, input.ipv6);
|
||||
return getNode(db, nodeId);
|
||||
}
|
||||
|
||||
export function updateNode(
|
||||
db: Db,
|
||||
nodeId: string,
|
||||
patch: {
|
||||
locationId?: string;
|
||||
hostname?: string;
|
||||
role?: string;
|
||||
indexNum?: number;
|
||||
providerTag?: string | null;
|
||||
notes?: string | null;
|
||||
ipv4?: string;
|
||||
ipv6?: string | null;
|
||||
syncStatus?: string;
|
||||
cfARecordId?: string | null;
|
||||
cfAaaaRecordId?: string | null;
|
||||
lastError?: string | null;
|
||||
},
|
||||
): NodeRow {
|
||||
const current = getNode(db, nodeId);
|
||||
if (patch.locationId) getLocation(db, patch.locationId);
|
||||
const updates: Partial<typeof nodes.$inferInsert> = {
|
||||
updated_at: now(),
|
||||
};
|
||||
if (patch.locationId !== undefined) updates.location_id = patch.locationId;
|
||||
if (patch.hostname !== undefined) updates.hostname = patch.hostname;
|
||||
if (patch.role !== undefined) updates.role = patch.role;
|
||||
if (patch.indexNum !== undefined) updates.index_num = patch.indexNum;
|
||||
if (patch.providerTag !== undefined) updates.provider_tag = patch.providerTag;
|
||||
if (patch.notes !== undefined) updates.notes = patch.notes;
|
||||
if (patch.syncStatus !== undefined) updates.sync_status = patch.syncStatus;
|
||||
if (patch.cfARecordId !== undefined)
|
||||
updates.cf_a_record_id = patch.cfARecordId;
|
||||
if (patch.cfAaaaRecordId !== undefined)
|
||||
updates.cf_aaaa_record_id = patch.cfAaaaRecordId;
|
||||
if (patch.lastError !== undefined) updates.last_error = patch.lastError;
|
||||
|
||||
db.update(nodes).set(updates).where(eq(nodes.id, nodeId)).run();
|
||||
|
||||
if (patch.ipv4 !== undefined) {
|
||||
const v6 =
|
||||
patch.ipv6 !== undefined
|
||||
? patch.ipv6
|
||||
: (current.addresses.find((a) => a.family === "v6")?.ip ?? null);
|
||||
setAddresses(db, nodeId, patch.ipv4, v6);
|
||||
} else if (patch.ipv6 !== undefined) {
|
||||
const v4 = current.addresses.find((a) => a.family === "v4")?.ip;
|
||||
if (!v4) throw new ConflictError("node has no IPv4");
|
||||
setAddresses(db, nodeId, v4, patch.ipv6);
|
||||
}
|
||||
|
||||
return getNode(db, nodeId);
|
||||
}
|
||||
|
||||
export function deleteNode(db: Db, nodeId: string): void {
|
||||
getNode(db, nodeId);
|
||||
const linked = db
|
||||
.select({ c: count() })
|
||||
.from(aliases)
|
||||
.where(eq(aliases.target_node_id, nodeId))
|
||||
.get()?.c;
|
||||
if (Number(linked ?? 0) > 0) {
|
||||
throw new ConflictError("node has aliases; retarget or delete them first");
|
||||
}
|
||||
db.delete(nodes).where(eq(nodes.id, nodeId)).run();
|
||||
}
|
||||
|
||||
function mapAlias(
|
||||
row: typeof aliases.$inferSelect,
|
||||
targetHostname?: string,
|
||||
): AliasRow {
|
||||
return {
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
name: row.name,
|
||||
purpose: row.purpose,
|
||||
mode: row.mode,
|
||||
targetNodeId: row.target_node_id,
|
||||
targetHostname,
|
||||
syncStatus: row.sync_status,
|
||||
cfRecordId: row.cf_record_id,
|
||||
lastError: row.last_error,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAliases(
|
||||
db: Db,
|
||||
filter: {
|
||||
zoneId?: string;
|
||||
purpose?: string;
|
||||
syncStatus?: string;
|
||||
q?: string;
|
||||
} = {},
|
||||
): AliasRow[] {
|
||||
let rows = db.select().from(aliases).all();
|
||||
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
|
||||
if (filter.purpose) rows = rows.filter((r) => r.purpose === filter.purpose);
|
||||
if (filter.syncStatus)
|
||||
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
|
||||
if (filter.q) {
|
||||
const q = filter.q.toLowerCase();
|
||||
rows = rows.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}
|
||||
const nodeHost = new Map(
|
||||
db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.all()
|
||||
.map((n) => [n.id, n.hostname]),
|
||||
);
|
||||
return rows
|
||||
.map((r) => mapAlias(r, nodeHost.get(r.target_node_id)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function getAlias(db: Db, aliasId: string): AliasRow {
|
||||
const row = db.select().from(aliases).where(eq(aliases.id, aliasId)).get();
|
||||
if (!row) throw new NotFoundError(`alias ${aliasId}`);
|
||||
const target = db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.where(eq(nodes.id, row.target_node_id))
|
||||
.get();
|
||||
return mapAlias(row, target?.hostname);
|
||||
}
|
||||
|
||||
export function createAlias(
|
||||
db: Db,
|
||||
input: {
|
||||
zoneId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
mode: string;
|
||||
targetNodeId: string;
|
||||
},
|
||||
): AliasRow {
|
||||
getZone(db, input.zoneId);
|
||||
getNode(db, input.targetNodeId);
|
||||
const dup = db
|
||||
.select()
|
||||
.from(aliases)
|
||||
.where(and(eq(aliases.zone_id, input.zoneId), eq(aliases.name, input.name)))
|
||||
.get();
|
||||
if (dup) throw new ConflictError(`alias ${input.name} already exists`);
|
||||
|
||||
const aliasId = id("alias");
|
||||
const ts = now();
|
||||
db.insert(aliases)
|
||||
.values({
|
||||
id: aliasId,
|
||||
zone_id: input.zoneId,
|
||||
name: input.name,
|
||||
purpose: input.purpose,
|
||||
mode: input.mode,
|
||||
target_node_id: input.targetNodeId,
|
||||
sync_status: "pending",
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
})
|
||||
.run();
|
||||
return getAlias(db, aliasId);
|
||||
}
|
||||
|
||||
export function updateAlias(
|
||||
db: Db,
|
||||
aliasId: string,
|
||||
patch: {
|
||||
name?: string;
|
||||
purpose?: string;
|
||||
mode?: string;
|
||||
targetNodeId?: string;
|
||||
syncStatus?: string;
|
||||
cfRecordId?: string | null;
|
||||
lastError?: string | null;
|
||||
},
|
||||
): AliasRow {
|
||||
getAlias(db, aliasId);
|
||||
if (patch.targetNodeId) getNode(db, patch.targetNodeId);
|
||||
const updates: Partial<typeof aliases.$inferInsert> = {
|
||||
updated_at: now(),
|
||||
};
|
||||
if (patch.name !== undefined) updates.name = patch.name;
|
||||
if (patch.purpose !== undefined) updates.purpose = patch.purpose;
|
||||
if (patch.mode !== undefined) updates.mode = patch.mode;
|
||||
if (patch.targetNodeId !== undefined)
|
||||
updates.target_node_id = patch.targetNodeId;
|
||||
if (patch.syncStatus !== undefined) updates.sync_status = patch.syncStatus;
|
||||
if (patch.cfRecordId !== undefined) updates.cf_record_id = patch.cfRecordId;
|
||||
if (patch.lastError !== undefined) updates.last_error = patch.lastError;
|
||||
db.update(aliases).set(updates).where(eq(aliases.id, aliasId)).run();
|
||||
return getAlias(db, aliasId);
|
||||
}
|
||||
|
||||
export function deleteAlias(db: Db, aliasId: string): void {
|
||||
getAlias(db, aliasId);
|
||||
db.delete(aliases).where(eq(aliases.id, aliasId)).run();
|
||||
}
|
||||
|
||||
export function createSyncJob(db: Db, zoneId: string): string {
|
||||
getZone(db, zoneId);
|
||||
const jobId = id("sync");
|
||||
db.insert(syncJobs)
|
||||
.values({
|
||||
id: jobId,
|
||||
zone_id: zoneId,
|
||||
status: "pending",
|
||||
created_at: now(),
|
||||
})
|
||||
.run();
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export function updateSyncJob(
|
||||
db: Db,
|
||||
jobId: string,
|
||||
patch: {
|
||||
status?: string;
|
||||
diffJson?: string | null;
|
||||
error?: string | null;
|
||||
finishedAt?: string | null;
|
||||
},
|
||||
) {
|
||||
const updates: Partial<typeof syncJobs.$inferInsert> = {};
|
||||
if (patch.status !== undefined) updates.status = patch.status;
|
||||
if (patch.diffJson !== undefined) updates.diff_json = patch.diffJson;
|
||||
if (patch.error !== undefined) updates.error = patch.error;
|
||||
if (patch.finishedAt !== undefined) updates.finished_at = patch.finishedAt;
|
||||
db.update(syncJobs).set(updates).where(eq(syncJobs.id, jobId)).run();
|
||||
}
|
||||
|
||||
export function getSyncJob(db: Db, jobId: string) {
|
||||
const row = db.select().from(syncJobs).where(eq(syncJobs.id, jobId)).get();
|
||||
if (!row) throw new NotFoundError(`sync job ${jobId}`);
|
||||
return {
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
status: row.status,
|
||||
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
|
||||
error: row.error,
|
||||
createdAt: row.created_at,
|
||||
finishedAt: row.finished_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function listSyncJobs(db: Db, zoneId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(syncJobs)
|
||||
.where(eq(syncJobs.zone_id, zoneId))
|
||||
.orderBy(sql`${syncJobs.created_at} DESC`)
|
||||
.all()
|
||||
.slice(0, 20)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
zoneId: row.zone_id,
|
||||
status: row.status,
|
||||
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
|
||||
error: row.error,
|
||||
createdAt: row.created_at,
|
||||
finishedAt: row.finished_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export function addSyncEvent(
|
||||
db: Db,
|
||||
jobId: string,
|
||||
event: {
|
||||
kind: string;
|
||||
recordName?: string;
|
||||
recordType?: string;
|
||||
detail?: string;
|
||||
},
|
||||
) {
|
||||
db.insert(syncEvents)
|
||||
.values({
|
||||
id: id("evt"),
|
||||
job_id: jobId,
|
||||
kind: event.kind,
|
||||
record_name: event.recordName ?? null,
|
||||
record_type: event.recordType ?? null,
|
||||
detail: event.detail ?? null,
|
||||
created_at: now(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function listIgnoredOrphans(db: Db, zoneId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(ignoredOrphans)
|
||||
.where(eq(ignoredOrphans.zone_id, zoneId))
|
||||
.all()
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
recordName: r.record_name,
|
||||
recordType: r.record_type,
|
||||
}));
|
||||
}
|
||||
|
||||
export function ignoreOrphan(
|
||||
db: Db,
|
||||
zoneId: string,
|
||||
recordName: string,
|
||||
recordType: string,
|
||||
) {
|
||||
getZone(db, zoneId);
|
||||
db.insert(ignoredOrphans)
|
||||
.values({
|
||||
id: id("ign"),
|
||||
zone_id: zoneId,
|
||||
record_name: recordName,
|
||||
record_type: recordType,
|
||||
created_at: now(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
|
||||
export function unignoreOrphan(
|
||||
db: Db,
|
||||
zoneId: string,
|
||||
recordName: string,
|
||||
recordType: string,
|
||||
) {
|
||||
db.delete(ignoredOrphans)
|
||||
.where(
|
||||
and(
|
||||
eq(ignoredOrphans.zone_id, zoneId),
|
||||
eq(ignoredOrphans.record_name, recordName),
|
||||
eq(ignoredOrphans.record_type, recordType),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export function dashboardCounts(db: Db) {
|
||||
const nodeRows = db.select().from(nodes).all();
|
||||
const aliasRows = db.select().from(aliases).all();
|
||||
const zoneRows = db.select().from(zones).all();
|
||||
|
||||
let syncOk = 0;
|
||||
let drift = 0;
|
||||
let nodesWithoutIp = 0;
|
||||
for (const n of nodeRows) {
|
||||
if (n.sync_status === "ok") syncOk += 1;
|
||||
if (n.sync_status === "drift" || n.sync_status === "missing") drift += 1;
|
||||
const addrs = loadAddresses(db, n.id);
|
||||
if (!addrs.some((a) => a.family === "v4")) nodesWithoutIp += 1;
|
||||
}
|
||||
let brokenAliases = 0;
|
||||
for (const a of aliasRows) {
|
||||
if (a.sync_status === "error" || a.sync_status === "missing")
|
||||
brokenAliases += 1;
|
||||
if (a.sync_status === "drift") drift += 1;
|
||||
if (a.sync_status === "ok") syncOk += 1;
|
||||
}
|
||||
|
||||
const lastSyncAt =
|
||||
zoneRows
|
||||
.map((z) => z.last_sync_at)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
return {
|
||||
nodes: nodeRows.length,
|
||||
aliases: aliasRows.length,
|
||||
syncOk,
|
||||
drift,
|
||||
nodesWithoutIp,
|
||||
brokenAliases,
|
||||
lastSyncAt,
|
||||
};
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from "./schema.js";
|
||||
export * from "./client.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./settings-repo.js";
|
||||
export * from "./fleet-repo.js";
|
||||
|
||||
+163
-1
@@ -1,11 +1,18 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const appSettings = sqliteTable("app_settings", {
|
||||
id: text("id").primaryKey(),
|
||||
show_quick_actions: integer("show_quick_actions", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
default_ttl: integer("default_ttl").notNull().default(300),
|
||||
naming_template: text("naming_template")
|
||||
.notNull()
|
||||
.default("{loc}-{role}{nn}.{zone}"),
|
||||
proxied_lock: integer("proxied_lock", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
@@ -14,6 +21,161 @@ export const appSettings = sqliteTable("app_settings", {
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const zones = sqliteTable("zones", {
|
||||
id: text("id").primaryKey(),
|
||||
cf_zone_id: text("cf_zone_id"),
|
||||
name: text("name").notNull().unique(),
|
||||
role: text("role").notNull().default("routing"),
|
||||
naming_template: text("naming_template")
|
||||
.notNull()
|
||||
.default("{loc}-{role}{nn}.{zone}"),
|
||||
default_ttl: integer("default_ttl").notNull().default(300),
|
||||
last_sync_at: text("last_sync_at"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const locations = sqliteTable("locations", {
|
||||
id: text("id").primaryKey(),
|
||||
code: text("code").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
country: text("country"),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const nodes = sqliteTable(
|
||||
"nodes",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id")
|
||||
.notNull()
|
||||
.references(() => zones.id, { onDelete: "cascade" }),
|
||||
location_id: text("location_id")
|
||||
.notNull()
|
||||
.references(() => locations.id),
|
||||
hostname: text("hostname").notNull(),
|
||||
role: text("role").notNull(),
|
||||
index_num: integer("index_num").notNull().default(1),
|
||||
provider_tag: text("provider_tag"),
|
||||
notes: text("notes"),
|
||||
sync_status: text("sync_status").notNull().default("pending"),
|
||||
cf_a_record_id: text("cf_a_record_id"),
|
||||
cf_aaaa_record_id: text("cf_aaaa_record_id"),
|
||||
last_error: text("last_error"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
},
|
||||
(t) => [uniqueIndex("nodes_zone_hostname").on(t.zone_id, t.hostname)],
|
||||
);
|
||||
|
||||
export const nodeAddresses = sqliteTable(
|
||||
"node_addresses",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
node_id: text("node_id")
|
||||
.notNull()
|
||||
.references(() => nodes.id, { onDelete: "cascade" }),
|
||||
family: text("family").notNull(),
|
||||
ip: text("ip").notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex("node_addresses_node_family").on(t.node_id, t.family)],
|
||||
);
|
||||
|
||||
export const aliases = sqliteTable(
|
||||
"aliases",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id")
|
||||
.notNull()
|
||||
.references(() => zones.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
purpose: text("purpose").notNull().default("geo"),
|
||||
mode: text("mode").notNull().default("primary"),
|
||||
target_node_id: text("target_node_id")
|
||||
.notNull()
|
||||
.references(() => nodes.id, { onDelete: "restrict" }),
|
||||
sync_status: text("sync_status").notNull().default("pending"),
|
||||
cf_record_id: text("cf_record_id"),
|
||||
last_error: text("last_error"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
},
|
||||
(t) => [uniqueIndex("aliases_zone_name").on(t.zone_id, t.name)],
|
||||
);
|
||||
|
||||
export const syncJobs = sqliteTable("sync_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id")
|
||||
.notNull()
|
||||
.references(() => zones.id, { onDelete: "cascade" }),
|
||||
status: text("status").notNull().default("pending"),
|
||||
diff_json: text("diff_json"),
|
||||
error: text("error"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
finished_at: text("finished_at"),
|
||||
});
|
||||
|
||||
export const syncEvents = sqliteTable("sync_events", {
|
||||
id: text("id").primaryKey(),
|
||||
job_id: text("job_id")
|
||||
.notNull()
|
||||
.references(() => syncJobs.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull(),
|
||||
record_name: text("record_name"),
|
||||
record_type: text("record_type"),
|
||||
detail: text("detail"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const ignoredOrphans = sqliteTable(
|
||||
"ignored_orphans",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
zone_id: text("zone_id")
|
||||
.notNull()
|
||||
.references(() => zones.id, { onDelete: "cascade" }),
|
||||
record_name: text("record_name").notNull(),
|
||||
record_type: text("record_type").notNull(),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("ignored_orphans_unique").on(
|
||||
t.zone_id,
|
||||
t.record_name,
|
||||
t.record_type,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const schema = {
|
||||
appSettings,
|
||||
zones,
|
||||
locations,
|
||||
nodes,
|
||||
nodeAddresses,
|
||||
aliases,
|
||||
syncJobs,
|
||||
syncEvents,
|
||||
ignoredOrphans,
|
||||
};
|
||||
|
||||
@@ -7,10 +7,16 @@ const SETTINGS_ID = "settings-main";
|
||||
export type AppSettingsDto = {
|
||||
id: string;
|
||||
showQuickActions: boolean;
|
||||
defaultTtl: number;
|
||||
namingTemplate: string;
|
||||
proxiedLock: boolean;
|
||||
};
|
||||
|
||||
export type AppSettingsPatch = {
|
||||
showQuickActions?: boolean;
|
||||
defaultTtl?: number;
|
||||
namingTemplate?: string;
|
||||
proxiedLock?: boolean;
|
||||
};
|
||||
|
||||
function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
|
||||
@@ -18,6 +24,9 @@ function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
|
||||
id: row.id,
|
||||
showQuickActions:
|
||||
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||
defaultTtl: row.default_ttl ?? 300,
|
||||
namingTemplate: row.naming_template ?? "{loc}-{role}{nn}.{zone}",
|
||||
proxiedLock: row.proxied_lock == null ? true : Boolean(row.proxied_lock),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +42,9 @@ function ensureRow(db: Db): typeof appSettings.$inferSelect {
|
||||
.values({
|
||||
id: SETTINGS_ID,
|
||||
show_quick_actions: true,
|
||||
default_ttl: 300,
|
||||
naming_template: "{loc}-{role}{nn}.{zone}",
|
||||
proxied_lock: true,
|
||||
})
|
||||
.run();
|
||||
|
||||
@@ -58,6 +70,15 @@ export function updateAppSettings(
|
||||
if (patch.showQuickActions !== undefined) {
|
||||
updates.show_quick_actions = patch.showQuickActions;
|
||||
}
|
||||
if (patch.defaultTtl !== undefined) {
|
||||
updates.default_ttl = patch.defaultTtl;
|
||||
}
|
||||
if (patch.namingTemplate !== undefined) {
|
||||
updates.naming_template = patch.namingTemplate;
|
||||
}
|
||||
if (patch.proxiedLock !== undefined) {
|
||||
updates.proxied_lock = patch.proxiedLock;
|
||||
}
|
||||
db.update(appSettings)
|
||||
.set(updates)
|
||||
.where(eq(appSettings.id, SETTINGS_ID))
|
||||
|
||||
Vendored
+423
-1
@@ -66,12 +66,434 @@ type LoginResponse = {
|
||||
};
|
||||
declare const appSettingsPatchSchema: z.ZodObject<{
|
||||
showQuickActions: z.ZodOptional<z.ZodBoolean>;
|
||||
defaultTtl: z.ZodOptional<z.ZodNumber>;
|
||||
namingTemplate: z.ZodOptional<z.ZodString>;
|
||||
proxiedLock: z.ZodOptional<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||
declare const appSettingsSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
showQuickActions: z.ZodBoolean;
|
||||
defaultTtl: z.ZodNumber;
|
||||
namingTemplate: z.ZodString;
|
||||
proxiedLock: z.ZodBoolean;
|
||||
cloudflareConfigured: z.ZodOptional<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
type AppSettings = z.infer<typeof appSettingsSchema>;
|
||||
|
||||
export { type AppSettings, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type JwtClaims, type LoginInput, type LoginRequest, type LoginResponse, appSettingsPatchSchema, appSettingsSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, loginSchema };
|
||||
declare const nodeRoleSchema: z.ZodEnum<{
|
||||
hub: "hub";
|
||||
gw: "gw";
|
||||
edge: "edge";
|
||||
ix: "ix";
|
||||
}>;
|
||||
type NodeRole = z.infer<typeof nodeRoleSchema>;
|
||||
declare const aliasPurposeSchema: z.ZodEnum<{
|
||||
custom: "custom";
|
||||
ix: "ix";
|
||||
geo: "geo";
|
||||
backup: "backup";
|
||||
admin: "admin";
|
||||
}>;
|
||||
type AliasPurpose = z.infer<typeof aliasPurposeSchema>;
|
||||
declare const aliasModeSchema: z.ZodEnum<{
|
||||
primary: "primary";
|
||||
pair: "pair";
|
||||
}>;
|
||||
type AliasMode = z.infer<typeof aliasModeSchema>;
|
||||
declare const syncStatusSchema: z.ZodEnum<{
|
||||
error: "error";
|
||||
pending: "pending";
|
||||
ok: "ok";
|
||||
drift: "drift";
|
||||
missing: "missing";
|
||||
}>;
|
||||
type SyncStatus = z.infer<typeof syncStatusSchema>;
|
||||
declare const addressFamilySchema: z.ZodEnum<{
|
||||
v4: "v4";
|
||||
v6: "v6";
|
||||
}>;
|
||||
declare const cfZoneSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
status: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type CfZone = z.infer<typeof cfZoneSchema>;
|
||||
declare const cfDnsRecordSchema: z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
type: z.ZodString;
|
||||
name: z.ZodString;
|
||||
content: z.ZodString;
|
||||
ttl: z.ZodNumber;
|
||||
proxied: z.ZodOptional<z.ZodBoolean>;
|
||||
priority: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
type CfDnsRecord = z.infer<typeof cfDnsRecordSchema>;
|
||||
declare const createDnsRecordPayloadSchema: z.ZodObject<{
|
||||
type: z.ZodString;
|
||||
name: z.ZodString;
|
||||
content: z.ZodString;
|
||||
ttl: z.ZodNumber;
|
||||
proxied: z.ZodOptional<z.ZodBoolean>;
|
||||
priority: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
type CreateDnsRecordPayload = z.infer<typeof createDnsRecordPayloadSchema>;
|
||||
declare const patchDnsRecordPayloadSchema: z.ZodObject<{
|
||||
type: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
content: z.ZodOptional<z.ZodString>;
|
||||
ttl: z.ZodOptional<z.ZodNumber>;
|
||||
proxied: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>;
|
||||
priority: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
|
||||
}, z.core.$strip>;
|
||||
type PatchDnsRecordPayload = z.infer<typeof patchDnsRecordPayloadSchema>;
|
||||
declare const locationSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
country: z.ZodNullable<z.ZodString>;
|
||||
sortOrder: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
type Location = z.infer<typeof locationSchema>;
|
||||
declare const zoneSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
cfZoneId: z.ZodNullable<z.ZodString>;
|
||||
name: z.ZodString;
|
||||
role: z.ZodString;
|
||||
namingTemplate: z.ZodString;
|
||||
defaultTtl: z.ZodNumber;
|
||||
lastSyncAt: z.ZodNullable<z.ZodString>;
|
||||
createdAt: z.ZodString;
|
||||
updatedAt: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
type Zone = z.infer<typeof zoneSchema>;
|
||||
declare const zoneCreateSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
cfZoneId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
role: z.ZodDefault<z.ZodString>;
|
||||
namingTemplate: z.ZodOptional<z.ZodString>;
|
||||
defaultTtl: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
type ZoneCreate = z.infer<typeof zoneCreateSchema>;
|
||||
declare const zonePatchSchema: z.ZodObject<{
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
cfZoneId: z.ZodOptional<z.ZodNullable<z.ZodOptional<z.ZodString>>>;
|
||||
role: z.ZodOptional<z.ZodDefault<z.ZodString>>;
|
||||
namingTemplate: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
||||
defaultTtl: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
|
||||
}, z.core.$strip>;
|
||||
type ZonePatch = z.infer<typeof zonePatchSchema>;
|
||||
declare const nodeAddressSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
family: z.ZodEnum<{
|
||||
v4: "v4";
|
||||
v6: "v6";
|
||||
}>;
|
||||
ip: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
type NodeAddress = z.infer<typeof nodeAddressSchema>;
|
||||
declare const nodeSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
zoneId: z.ZodString;
|
||||
locationId: z.ZodString;
|
||||
locationCode: z.ZodOptional<z.ZodString>;
|
||||
locationName: z.ZodOptional<z.ZodString>;
|
||||
hostname: z.ZodString;
|
||||
role: z.ZodEnum<{
|
||||
hub: "hub";
|
||||
gw: "gw";
|
||||
edge: "edge";
|
||||
ix: "ix";
|
||||
}>;
|
||||
indexNum: z.ZodNumber;
|
||||
providerTag: z.ZodNullable<z.ZodString>;
|
||||
notes: z.ZodNullable<z.ZodString>;
|
||||
syncStatus: z.ZodEnum<{
|
||||
error: "error";
|
||||
pending: "pending";
|
||||
ok: "ok";
|
||||
drift: "drift";
|
||||
missing: "missing";
|
||||
}>;
|
||||
cfARecordId: z.ZodNullable<z.ZodString>;
|
||||
cfAaaaRecordId: z.ZodNullable<z.ZodString>;
|
||||
lastError: z.ZodNullable<z.ZodString>;
|
||||
addresses: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
family: z.ZodEnum<{
|
||||
v4: "v4";
|
||||
v6: "v6";
|
||||
}>;
|
||||
ip: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
aliasCount: z.ZodOptional<z.ZodNumber>;
|
||||
createdAt: z.ZodString;
|
||||
updatedAt: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
type Node = z.infer<typeof nodeSchema>;
|
||||
declare const nodeCreateSchema: z.ZodObject<{
|
||||
zoneId: z.ZodString;
|
||||
locationId: z.ZodString;
|
||||
role: z.ZodEnum<{
|
||||
hub: "hub";
|
||||
gw: "gw";
|
||||
edge: "edge";
|
||||
ix: "ix";
|
||||
}>;
|
||||
indexNum: z.ZodDefault<z.ZodNumber>;
|
||||
hostname: z.ZodOptional<z.ZodString>;
|
||||
providerTag: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
ipv4: z.ZodString;
|
||||
ipv6: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
type NodeCreate = z.infer<typeof nodeCreateSchema>;
|
||||
declare const nodePatchSchema: z.ZodObject<{
|
||||
locationId: z.ZodOptional<z.ZodString>;
|
||||
role: z.ZodOptional<z.ZodEnum<{
|
||||
hub: "hub";
|
||||
gw: "gw";
|
||||
edge: "edge";
|
||||
ix: "ix";
|
||||
}>>;
|
||||
indexNum: z.ZodOptional<z.ZodNumber>;
|
||||
hostname: z.ZodOptional<z.ZodString>;
|
||||
providerTag: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
ipv4: z.ZodOptional<z.ZodString>;
|
||||
ipv6: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
type NodePatch = z.infer<typeof nodePatchSchema>;
|
||||
declare const aliasSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
zoneId: z.ZodString;
|
||||
name: z.ZodString;
|
||||
purpose: z.ZodEnum<{
|
||||
custom: "custom";
|
||||
ix: "ix";
|
||||
geo: "geo";
|
||||
backup: "backup";
|
||||
admin: "admin";
|
||||
}>;
|
||||
mode: z.ZodEnum<{
|
||||
primary: "primary";
|
||||
pair: "pair";
|
||||
}>;
|
||||
targetNodeId: z.ZodString;
|
||||
targetHostname: z.ZodOptional<z.ZodString>;
|
||||
syncStatus: z.ZodEnum<{
|
||||
error: "error";
|
||||
pending: "pending";
|
||||
ok: "ok";
|
||||
drift: "drift";
|
||||
missing: "missing";
|
||||
}>;
|
||||
cfRecordId: z.ZodNullable<z.ZodString>;
|
||||
lastError: z.ZodNullable<z.ZodString>;
|
||||
createdAt: z.ZodString;
|
||||
updatedAt: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
type Alias = z.infer<typeof aliasSchema>;
|
||||
declare const aliasCreateSchema: z.ZodObject<{
|
||||
zoneId: z.ZodString;
|
||||
name: z.ZodString;
|
||||
purpose: z.ZodDefault<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
ix: "ix";
|
||||
geo: "geo";
|
||||
backup: "backup";
|
||||
admin: "admin";
|
||||
}>>;
|
||||
mode: z.ZodDefault<z.ZodEnum<{
|
||||
primary: "primary";
|
||||
pair: "pair";
|
||||
}>>;
|
||||
targetNodeId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
type AliasCreate = z.infer<typeof aliasCreateSchema>;
|
||||
declare const aliasPatchSchema: z.ZodObject<{
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
purpose: z.ZodOptional<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
ix: "ix";
|
||||
geo: "geo";
|
||||
backup: "backup";
|
||||
admin: "admin";
|
||||
}>>;
|
||||
mode: z.ZodOptional<z.ZodEnum<{
|
||||
primary: "primary";
|
||||
pair: "pair";
|
||||
}>>;
|
||||
targetNodeId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type AliasPatch = z.infer<typeof aliasPatchSchema>;
|
||||
declare const aliasRetargetSchema: z.ZodObject<{
|
||||
targetNodeId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
type AliasRetarget = z.infer<typeof aliasRetargetSchema>;
|
||||
declare const syncDiffOpSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
kind: z.ZodEnum<{
|
||||
create: "create";
|
||||
update: "update";
|
||||
delete: "delete";
|
||||
orphan: "orphan";
|
||||
proxy_violation: "proxy_violation";
|
||||
noop: "noop";
|
||||
}>;
|
||||
entityType: z.ZodEnum<{
|
||||
orphan: "orphan";
|
||||
node_a: "node_a";
|
||||
node_aaaa: "node_aaaa";
|
||||
alias: "alias";
|
||||
}>;
|
||||
entityId: z.ZodNullable<z.ZodString>;
|
||||
recordName: z.ZodString;
|
||||
recordType: z.ZodString;
|
||||
desired: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
observed: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
detail: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type SyncDiffOp = z.infer<typeof syncDiffOpSchema>;
|
||||
declare const syncJobSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
zoneId: z.ZodString;
|
||||
status: z.ZodEnum<{
|
||||
pending: "pending";
|
||||
running: "running";
|
||||
done: "done";
|
||||
failed: "failed";
|
||||
}>;
|
||||
diff: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
kind: z.ZodEnum<{
|
||||
create: "create";
|
||||
update: "update";
|
||||
delete: "delete";
|
||||
orphan: "orphan";
|
||||
proxy_violation: "proxy_violation";
|
||||
noop: "noop";
|
||||
}>;
|
||||
entityType: z.ZodEnum<{
|
||||
orphan: "orphan";
|
||||
node_a: "node_a";
|
||||
node_aaaa: "node_aaaa";
|
||||
alias: "alias";
|
||||
}>;
|
||||
entityId: z.ZodNullable<z.ZodString>;
|
||||
recordName: z.ZodString;
|
||||
recordType: z.ZodString;
|
||||
desired: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
observed: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
detail: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
createdAt: z.ZodString;
|
||||
finishedAt: z.ZodNullable<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type SyncJob = z.infer<typeof syncJobSchema>;
|
||||
declare const syncApplySchema: z.ZodObject<{
|
||||
opIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
type SyncApply = z.infer<typeof syncApplySchema>;
|
||||
declare const dashboardStatsSchema: z.ZodObject<{
|
||||
nodes: z.ZodNumber;
|
||||
aliases: z.ZodNumber;
|
||||
syncOk: z.ZodNumber;
|
||||
drift: z.ZodNumber;
|
||||
proxyViolations: z.ZodNumber;
|
||||
orphans: z.ZodNumber;
|
||||
lastSyncAt: z.ZodNullable<z.ZodString>;
|
||||
nodesWithoutIp: z.ZodNumber;
|
||||
brokenAliases: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
type DashboardStats = z.infer<typeof dashboardStatsSchema>;
|
||||
declare const topologyNodeSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
hostname: z.ZodString;
|
||||
role: z.ZodEnum<{
|
||||
hub: "hub";
|
||||
gw: "gw";
|
||||
edge: "edge";
|
||||
ix: "ix";
|
||||
}>;
|
||||
locationCode: z.ZodString;
|
||||
ipv4: z.ZodNullable<z.ZodString>;
|
||||
syncStatus: z.ZodEnum<{
|
||||
error: "error";
|
||||
pending: "pending";
|
||||
ok: "ok";
|
||||
drift: "drift";
|
||||
missing: "missing";
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
declare const topologyEdgeSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
aliasName: z.ZodString;
|
||||
purpose: z.ZodEnum<{
|
||||
custom: "custom";
|
||||
ix: "ix";
|
||||
geo: "geo";
|
||||
backup: "backup";
|
||||
admin: "admin";
|
||||
}>;
|
||||
fromNodeId: z.ZodString;
|
||||
toHostname: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
declare const topologySchema: z.ZodObject<{
|
||||
nodes: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
hostname: z.ZodString;
|
||||
role: z.ZodEnum<{
|
||||
hub: "hub";
|
||||
gw: "gw";
|
||||
edge: "edge";
|
||||
ix: "ix";
|
||||
}>;
|
||||
locationCode: z.ZodString;
|
||||
ipv4: z.ZodNullable<z.ZodString>;
|
||||
syncStatus: z.ZodEnum<{
|
||||
error: "error";
|
||||
pending: "pending";
|
||||
ok: "ok";
|
||||
drift: "drift";
|
||||
missing: "missing";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
edges: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
aliasName: z.ZodString;
|
||||
purpose: z.ZodEnum<{
|
||||
custom: "custom";
|
||||
ix: "ix";
|
||||
geo: "geo";
|
||||
backup: "backup";
|
||||
admin: "admin";
|
||||
}>;
|
||||
fromNodeId: z.ZodString;
|
||||
toHostname: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
locations: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
country: z.ZodNullable<z.ZodString>;
|
||||
sortOrder: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
type Topology = z.infer<typeof topologySchema>;
|
||||
declare const bindExportSchema: z.ZodObject<{
|
||||
zoneName: z.ZodString;
|
||||
content: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
type BindExport = z.infer<typeof bindExportSchema>;
|
||||
declare const orphanIgnoreSchema: z.ZodObject<{
|
||||
recordName: z.ZodString;
|
||||
recordType: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
|
||||
declare class ValidationError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
|
||||
export { type Alias, type AliasCreate, type AliasMode, type AliasPatch, type AliasPurpose, type AliasRetarget, type AppSettings, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type BindExport, type CfDnsRecord, type CfZone, type CreateDnsRecordPayload, type DashboardStats, type JwtClaims, type Location, type LoginInput, type LoginRequest, type LoginResponse, type Node, type NodeAddress, type NodeCreate, type NodePatch, type NodeRole, type PatchDnsRecordPayload, type SyncApply, type SyncDiffOp, type SyncJob, type SyncStatus, type Topology, ValidationError, type Zone, type ZoneCreate, type ZonePatch, addressFamilySchema, aliasCreateSchema, aliasModeSchema, aliasPatchSchema, aliasPurposeSchema, aliasRetargetSchema, aliasSchema, appSettingsPatchSchema, appSettingsSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindExportSchema, cfDnsRecordSchema, cfZoneSchema, createDnsRecordPayloadSchema, dashboardStatsSchema, locationSchema, loginSchema, nodeAddressSchema, nodeCreateSchema, nodePatchSchema, nodeRoleSchema, nodeSchema, orphanIgnoreSchema, patchDnsRecordPayloadSchema, syncApplySchema, syncDiffOpSchema, syncJobSchema, syncStatusSchema, topologyEdgeSchema, topologyNodeSchema, topologySchema, zoneCreateSchema, zonePatchSchema, zoneSchema };
|
||||
|
||||
Vendored
+263
-3
@@ -29,17 +29,277 @@ var loginSchema = z2.object({
|
||||
password: z2.string().min(1)
|
||||
});
|
||||
var appSettingsPatchSchema = z2.object({
|
||||
showQuickActions: z2.boolean().optional()
|
||||
showQuickActions: z2.boolean().optional(),
|
||||
defaultTtl: z2.number().int().min(60).max(86400).optional(),
|
||||
namingTemplate: z2.string().min(1).optional(),
|
||||
proxiedLock: z2.boolean().optional()
|
||||
});
|
||||
var appSettingsSchema = z2.object({
|
||||
id: z2.string(),
|
||||
showQuickActions: z2.boolean()
|
||||
showQuickActions: z2.boolean(),
|
||||
defaultTtl: z2.number(),
|
||||
namingTemplate: z2.string(),
|
||||
proxiedLock: z2.boolean(),
|
||||
cloudflareConfigured: z2.boolean().optional()
|
||||
});
|
||||
|
||||
// src/fleet.ts
|
||||
import { z as z3 } from "zod";
|
||||
var nodeRoleSchema = z3.enum(["hub", "gw", "edge", "ix"]);
|
||||
var aliasPurposeSchema = z3.enum([
|
||||
"geo",
|
||||
"ix",
|
||||
"backup",
|
||||
"admin",
|
||||
"custom"
|
||||
]);
|
||||
var aliasModeSchema = z3.enum(["primary", "pair"]);
|
||||
var syncStatusSchema = z3.enum([
|
||||
"pending",
|
||||
"ok",
|
||||
"drift",
|
||||
"missing",
|
||||
"error"
|
||||
]);
|
||||
var addressFamilySchema = z3.enum(["v4", "v6"]);
|
||||
var cfZoneSchema = z3.object({
|
||||
id: z3.string(),
|
||||
name: z3.string(),
|
||||
status: z3.string().optional()
|
||||
});
|
||||
var cfDnsRecordSchema = z3.object({
|
||||
id: z3.string().optional(),
|
||||
type: z3.string(),
|
||||
name: z3.string(),
|
||||
content: z3.string(),
|
||||
ttl: z3.number(),
|
||||
proxied: z3.boolean().optional(),
|
||||
priority: z3.number().optional()
|
||||
});
|
||||
var createDnsRecordPayloadSchema = z3.object({
|
||||
type: z3.string(),
|
||||
name: z3.string(),
|
||||
content: z3.string(),
|
||||
ttl: z3.number(),
|
||||
proxied: z3.boolean().optional(),
|
||||
priority: z3.number().optional()
|
||||
});
|
||||
var patchDnsRecordPayloadSchema = createDnsRecordPayloadSchema.partial();
|
||||
var locationSchema = z3.object({
|
||||
id: z3.string(),
|
||||
code: z3.string(),
|
||||
name: z3.string(),
|
||||
country: z3.string().nullable(),
|
||||
sortOrder: z3.number()
|
||||
});
|
||||
var zoneSchema = z3.object({
|
||||
id: z3.string(),
|
||||
cfZoneId: z3.string().nullable(),
|
||||
name: z3.string(),
|
||||
role: z3.string(),
|
||||
namingTemplate: z3.string(),
|
||||
defaultTtl: z3.number(),
|
||||
lastSyncAt: z3.string().nullable(),
|
||||
createdAt: z3.string(),
|
||||
updatedAt: z3.string()
|
||||
});
|
||||
var zoneCreateSchema = z3.object({
|
||||
name: z3.string().min(1),
|
||||
cfZoneId: z3.string().optional().nullable(),
|
||||
role: z3.string().default("routing"),
|
||||
namingTemplate: z3.string().optional(),
|
||||
defaultTtl: z3.number().int().min(60).max(86400).optional()
|
||||
});
|
||||
var zonePatchSchema = zoneCreateSchema.partial();
|
||||
var nodeAddressSchema = z3.object({
|
||||
id: z3.string(),
|
||||
family: addressFamilySchema,
|
||||
ip: z3.string()
|
||||
});
|
||||
var nodeSchema = z3.object({
|
||||
id: z3.string(),
|
||||
zoneId: z3.string(),
|
||||
locationId: z3.string(),
|
||||
locationCode: z3.string().optional(),
|
||||
locationName: z3.string().optional(),
|
||||
hostname: z3.string(),
|
||||
role: nodeRoleSchema,
|
||||
indexNum: z3.number(),
|
||||
providerTag: z3.string().nullable(),
|
||||
notes: z3.string().nullable(),
|
||||
syncStatus: syncStatusSchema,
|
||||
cfARecordId: z3.string().nullable(),
|
||||
cfAaaaRecordId: z3.string().nullable(),
|
||||
lastError: z3.string().nullable(),
|
||||
addresses: z3.array(nodeAddressSchema).default([]),
|
||||
aliasCount: z3.number().optional(),
|
||||
createdAt: z3.string(),
|
||||
updatedAt: z3.string()
|
||||
});
|
||||
var nodeCreateSchema = z3.object({
|
||||
zoneId: z3.string().min(1),
|
||||
locationId: z3.string().min(1),
|
||||
role: nodeRoleSchema,
|
||||
indexNum: z3.number().int().min(1).max(99).default(1),
|
||||
hostname: z3.string().min(1).optional(),
|
||||
providerTag: z3.string().optional().nullable(),
|
||||
notes: z3.string().optional().nullable(),
|
||||
ipv4: z3.string().min(1),
|
||||
ipv6: z3.string().optional().nullable()
|
||||
});
|
||||
var nodePatchSchema = z3.object({
|
||||
locationId: z3.string().optional(),
|
||||
role: nodeRoleSchema.optional(),
|
||||
indexNum: z3.number().int().min(1).max(99).optional(),
|
||||
hostname: z3.string().min(1).optional(),
|
||||
providerTag: z3.string().optional().nullable(),
|
||||
notes: z3.string().optional().nullable(),
|
||||
ipv4: z3.string().min(1).optional(),
|
||||
ipv6: z3.string().optional().nullable()
|
||||
});
|
||||
var aliasSchema = z3.object({
|
||||
id: z3.string(),
|
||||
zoneId: z3.string(),
|
||||
name: z3.string(),
|
||||
purpose: aliasPurposeSchema,
|
||||
mode: aliasModeSchema,
|
||||
targetNodeId: z3.string(),
|
||||
targetHostname: z3.string().optional(),
|
||||
syncStatus: syncStatusSchema,
|
||||
cfRecordId: z3.string().nullable(),
|
||||
lastError: z3.string().nullable(),
|
||||
createdAt: z3.string(),
|
||||
updatedAt: z3.string()
|
||||
});
|
||||
var aliasCreateSchema = z3.object({
|
||||
zoneId: z3.string().min(1),
|
||||
name: z3.string().min(1),
|
||||
purpose: aliasPurposeSchema.default("geo"),
|
||||
mode: aliasModeSchema.default("primary"),
|
||||
targetNodeId: z3.string().min(1)
|
||||
});
|
||||
var aliasPatchSchema = z3.object({
|
||||
name: z3.string().min(1).optional(),
|
||||
purpose: aliasPurposeSchema.optional(),
|
||||
mode: aliasModeSchema.optional(),
|
||||
targetNodeId: z3.string().min(1).optional()
|
||||
});
|
||||
var aliasRetargetSchema = z3.object({
|
||||
targetNodeId: z3.string().min(1)
|
||||
});
|
||||
var syncDiffOpSchema = z3.object({
|
||||
id: z3.string(),
|
||||
kind: z3.enum([
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"orphan",
|
||||
"proxy_violation",
|
||||
"noop"
|
||||
]),
|
||||
entityType: z3.enum(["node_a", "node_aaaa", "alias", "orphan"]),
|
||||
entityId: z3.string().nullable(),
|
||||
recordName: z3.string(),
|
||||
recordType: z3.string(),
|
||||
desired: z3.record(z3.string(), z3.unknown()).nullable(),
|
||||
observed: z3.record(z3.string(), z3.unknown()).nullable(),
|
||||
detail: z3.string().optional()
|
||||
});
|
||||
var syncJobSchema = z3.object({
|
||||
id: z3.string(),
|
||||
zoneId: z3.string(),
|
||||
status: z3.enum(["pending", "running", "done", "failed"]),
|
||||
diff: z3.array(syncDiffOpSchema).optional(),
|
||||
error: z3.string().nullable(),
|
||||
createdAt: z3.string(),
|
||||
finishedAt: z3.string().nullable()
|
||||
});
|
||||
var syncApplySchema = z3.object({
|
||||
opIds: z3.array(z3.string()).optional()
|
||||
});
|
||||
var dashboardStatsSchema = z3.object({
|
||||
nodes: z3.number(),
|
||||
aliases: z3.number(),
|
||||
syncOk: z3.number(),
|
||||
drift: z3.number(),
|
||||
proxyViolations: z3.number(),
|
||||
orphans: z3.number(),
|
||||
lastSyncAt: z3.string().nullable(),
|
||||
nodesWithoutIp: z3.number(),
|
||||
brokenAliases: z3.number()
|
||||
});
|
||||
var topologyNodeSchema = z3.object({
|
||||
id: z3.string(),
|
||||
hostname: z3.string(),
|
||||
role: nodeRoleSchema,
|
||||
locationCode: z3.string(),
|
||||
ipv4: z3.string().nullable(),
|
||||
syncStatus: syncStatusSchema
|
||||
});
|
||||
var topologyEdgeSchema = z3.object({
|
||||
id: z3.string(),
|
||||
aliasName: z3.string(),
|
||||
purpose: aliasPurposeSchema,
|
||||
fromNodeId: z3.string(),
|
||||
toHostname: z3.string()
|
||||
});
|
||||
var topologySchema = z3.object({
|
||||
nodes: z3.array(topologyNodeSchema),
|
||||
edges: z3.array(topologyEdgeSchema),
|
||||
locations: z3.array(locationSchema)
|
||||
});
|
||||
var bindExportSchema = z3.object({
|
||||
zoneName: z3.string(),
|
||||
content: z3.string()
|
||||
});
|
||||
var orphanIgnoreSchema = z3.object({
|
||||
recordName: z3.string().min(1),
|
||||
recordType: z3.string().min(1)
|
||||
});
|
||||
|
||||
// src/index.ts
|
||||
var ValidationError = class extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
};
|
||||
export {
|
||||
ValidationError,
|
||||
addressFamilySchema,
|
||||
aliasCreateSchema,
|
||||
aliasModeSchema,
|
||||
aliasPatchSchema,
|
||||
aliasPurposeSchema,
|
||||
aliasRetargetSchema,
|
||||
aliasSchema,
|
||||
appSettingsPatchSchema,
|
||||
appSettingsSchema,
|
||||
appSwitcherConfigSchema,
|
||||
appSwitcherEntrySchema,
|
||||
appSwitcherIconSchema,
|
||||
loginSchema
|
||||
bindExportSchema,
|
||||
cfDnsRecordSchema,
|
||||
cfZoneSchema,
|
||||
createDnsRecordPayloadSchema,
|
||||
dashboardStatsSchema,
|
||||
locationSchema,
|
||||
loginSchema,
|
||||
nodeAddressSchema,
|
||||
nodeCreateSchema,
|
||||
nodePatchSchema,
|
||||
nodeRoleSchema,
|
||||
nodeSchema,
|
||||
orphanIgnoreSchema,
|
||||
patchDnsRecordPayloadSchema,
|
||||
syncApplySchema,
|
||||
syncDiffOpSchema,
|
||||
syncJobSchema,
|
||||
syncStatusSchema,
|
||||
topologyEdgeSchema,
|
||||
topologyNodeSchema,
|
||||
topologySchema,
|
||||
zoneCreateSchema,
|
||||
zonePatchSchema,
|
||||
zoneSchema
|
||||
};
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const nodeRoleSchema = z.enum(["hub", "gw", "edge", "ix"]);
|
||||
export type NodeRole = z.infer<typeof nodeRoleSchema>;
|
||||
|
||||
export const aliasPurposeSchema = z.enum([
|
||||
"geo",
|
||||
"ix",
|
||||
"backup",
|
||||
"admin",
|
||||
"custom",
|
||||
]);
|
||||
export type AliasPurpose = z.infer<typeof aliasPurposeSchema>;
|
||||
|
||||
export const aliasModeSchema = z.enum(["primary", "pair"]);
|
||||
export type AliasMode = z.infer<typeof aliasModeSchema>;
|
||||
|
||||
export const syncStatusSchema = z.enum([
|
||||
"pending",
|
||||
"ok",
|
||||
"drift",
|
||||
"missing",
|
||||
"error",
|
||||
]);
|
||||
export type SyncStatus = z.infer<typeof syncStatusSchema>;
|
||||
|
||||
export const addressFamilySchema = z.enum(["v4", "v6"]);
|
||||
|
||||
export const cfZoneSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
export type CfZone = z.infer<typeof cfZoneSchema>;
|
||||
|
||||
export const cfDnsRecordSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
content: z.string(),
|
||||
ttl: z.number(),
|
||||
proxied: z.boolean().optional(),
|
||||
priority: z.number().optional(),
|
||||
});
|
||||
export type CfDnsRecord = z.infer<typeof cfDnsRecordSchema>;
|
||||
|
||||
export const createDnsRecordPayloadSchema = z.object({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
content: z.string(),
|
||||
ttl: z.number(),
|
||||
proxied: z.boolean().optional(),
|
||||
priority: z.number().optional(),
|
||||
});
|
||||
export type CreateDnsRecordPayload = z.infer<typeof createDnsRecordPayloadSchema>;
|
||||
|
||||
export const patchDnsRecordPayloadSchema = createDnsRecordPayloadSchema.partial();
|
||||
export type PatchDnsRecordPayload = z.infer<typeof patchDnsRecordPayloadSchema>;
|
||||
|
||||
export const locationSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
country: z.string().nullable(),
|
||||
sortOrder: z.number(),
|
||||
});
|
||||
export type Location = z.infer<typeof locationSchema>;
|
||||
|
||||
export const zoneSchema = z.object({
|
||||
id: z.string(),
|
||||
cfZoneId: z.string().nullable(),
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
namingTemplate: z.string(),
|
||||
defaultTtl: z.number(),
|
||||
lastSyncAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Zone = z.infer<typeof zoneSchema>;
|
||||
|
||||
export const zoneCreateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
cfZoneId: z.string().optional().nullable(),
|
||||
role: z.string().default("routing"),
|
||||
namingTemplate: z.string().optional(),
|
||||
defaultTtl: z.number().int().min(60).max(86400).optional(),
|
||||
});
|
||||
export type ZoneCreate = z.infer<typeof zoneCreateSchema>;
|
||||
|
||||
export const zonePatchSchema = zoneCreateSchema.partial();
|
||||
export type ZonePatch = z.infer<typeof zonePatchSchema>;
|
||||
|
||||
export const nodeAddressSchema = z.object({
|
||||
id: z.string(),
|
||||
family: addressFamilySchema,
|
||||
ip: z.string(),
|
||||
});
|
||||
export type NodeAddress = z.infer<typeof nodeAddressSchema>;
|
||||
|
||||
export const nodeSchema = z.object({
|
||||
id: z.string(),
|
||||
zoneId: z.string(),
|
||||
locationId: z.string(),
|
||||
locationCode: z.string().optional(),
|
||||
locationName: z.string().optional(),
|
||||
hostname: z.string(),
|
||||
role: nodeRoleSchema,
|
||||
indexNum: z.number(),
|
||||
providerTag: z.string().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
syncStatus: syncStatusSchema,
|
||||
cfARecordId: z.string().nullable(),
|
||||
cfAaaaRecordId: z.string().nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
addresses: z.array(nodeAddressSchema).default([]),
|
||||
aliasCount: z.number().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Node = z.infer<typeof nodeSchema>;
|
||||
|
||||
export const nodeCreateSchema = z.object({
|
||||
zoneId: z.string().min(1),
|
||||
locationId: z.string().min(1),
|
||||
role: nodeRoleSchema,
|
||||
indexNum: z.number().int().min(1).max(99).default(1),
|
||||
hostname: z.string().min(1).optional(),
|
||||
providerTag: z.string().optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
ipv4: z.string().min(1),
|
||||
ipv6: z.string().optional().nullable(),
|
||||
});
|
||||
export type NodeCreate = z.infer<typeof nodeCreateSchema>;
|
||||
|
||||
export const nodePatchSchema = z.object({
|
||||
locationId: z.string().optional(),
|
||||
role: nodeRoleSchema.optional(),
|
||||
indexNum: z.number().int().min(1).max(99).optional(),
|
||||
hostname: z.string().min(1).optional(),
|
||||
providerTag: z.string().optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
ipv4: z.string().min(1).optional(),
|
||||
ipv6: z.string().optional().nullable(),
|
||||
});
|
||||
export type NodePatch = z.infer<typeof nodePatchSchema>;
|
||||
|
||||
export const aliasSchema = z.object({
|
||||
id: z.string(),
|
||||
zoneId: z.string(),
|
||||
name: z.string(),
|
||||
purpose: aliasPurposeSchema,
|
||||
mode: aliasModeSchema,
|
||||
targetNodeId: z.string(),
|
||||
targetHostname: z.string().optional(),
|
||||
syncStatus: syncStatusSchema,
|
||||
cfRecordId: z.string().nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Alias = z.infer<typeof aliasSchema>;
|
||||
|
||||
export const aliasCreateSchema = z.object({
|
||||
zoneId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
purpose: aliasPurposeSchema.default("geo"),
|
||||
mode: aliasModeSchema.default("primary"),
|
||||
targetNodeId: z.string().min(1),
|
||||
});
|
||||
export type AliasCreate = z.infer<typeof aliasCreateSchema>;
|
||||
|
||||
export const aliasPatchSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
purpose: aliasPurposeSchema.optional(),
|
||||
mode: aliasModeSchema.optional(),
|
||||
targetNodeId: z.string().min(1).optional(),
|
||||
});
|
||||
export type AliasPatch = z.infer<typeof aliasPatchSchema>;
|
||||
|
||||
export const aliasRetargetSchema = z.object({
|
||||
targetNodeId: z.string().min(1),
|
||||
});
|
||||
export type AliasRetarget = z.infer<typeof aliasRetargetSchema>;
|
||||
|
||||
export const syncDiffOpSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum([
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"orphan",
|
||||
"proxy_violation",
|
||||
"noop",
|
||||
]),
|
||||
entityType: z.enum(["node_a", "node_aaaa", "alias", "orphan"]),
|
||||
entityId: z.string().nullable(),
|
||||
recordName: z.string(),
|
||||
recordType: z.string(),
|
||||
desired: z.record(z.string(), z.unknown()).nullable(),
|
||||
observed: z.record(z.string(), z.unknown()).nullable(),
|
||||
detail: z.string().optional(),
|
||||
});
|
||||
export type SyncDiffOp = z.infer<typeof syncDiffOpSchema>;
|
||||
|
||||
export const syncJobSchema = z.object({
|
||||
id: z.string(),
|
||||
zoneId: z.string(),
|
||||
status: z.enum(["pending", "running", "done", "failed"]),
|
||||
diff: z.array(syncDiffOpSchema).optional(),
|
||||
error: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
finishedAt: z.string().nullable(),
|
||||
});
|
||||
export type SyncJob = z.infer<typeof syncJobSchema>;
|
||||
|
||||
export const syncApplySchema = z.object({
|
||||
opIds: z.array(z.string()).optional(),
|
||||
});
|
||||
export type SyncApply = z.infer<typeof syncApplySchema>;
|
||||
|
||||
export const dashboardStatsSchema = z.object({
|
||||
nodes: z.number(),
|
||||
aliases: z.number(),
|
||||
syncOk: z.number(),
|
||||
drift: z.number(),
|
||||
proxyViolations: z.number(),
|
||||
orphans: z.number(),
|
||||
lastSyncAt: z.string().nullable(),
|
||||
nodesWithoutIp: z.number(),
|
||||
brokenAliases: z.number(),
|
||||
});
|
||||
export type DashboardStats = z.infer<typeof dashboardStatsSchema>;
|
||||
|
||||
export const topologyNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
hostname: z.string(),
|
||||
role: nodeRoleSchema,
|
||||
locationCode: z.string(),
|
||||
ipv4: z.string().nullable(),
|
||||
syncStatus: syncStatusSchema,
|
||||
});
|
||||
|
||||
export const topologyEdgeSchema = z.object({
|
||||
id: z.string(),
|
||||
aliasName: z.string(),
|
||||
purpose: aliasPurposeSchema,
|
||||
fromNodeId: z.string(),
|
||||
toHostname: z.string(),
|
||||
});
|
||||
|
||||
export const topologySchema = z.object({
|
||||
nodes: z.array(topologyNodeSchema),
|
||||
edges: z.array(topologyEdgeSchema),
|
||||
locations: z.array(locationSchema),
|
||||
});
|
||||
export type Topology = z.infer<typeof topologySchema>;
|
||||
|
||||
export const bindExportSchema = z.object({
|
||||
zoneName: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export type BindExport = z.infer<typeof bindExportSchema>;
|
||||
|
||||
export const orphanIgnoreSchema = z.object({
|
||||
recordName: z.string().min(1),
|
||||
recordType: z.string().min(1),
|
||||
});
|
||||
@@ -1,2 +1,10 @@
|
||||
export * from "./app-switcher.js";
|
||||
export * from "./settings.js";
|
||||
export * from "./fleet.js";
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ export type LoginResponse = {
|
||||
|
||||
export const appSettingsPatchSchema = z.object({
|
||||
showQuickActions: z.boolean().optional(),
|
||||
defaultTtl: z.number().int().min(60).max(86400).optional(),
|
||||
namingTemplate: z.string().min(1).optional(),
|
||||
proxiedLock: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||
@@ -32,6 +35,10 @@ export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||
export const appSettingsSchema = z.object({
|
||||
id: z.string(),
|
||||
showQuickActions: z.boolean(),
|
||||
defaultTtl: z.number(),
|
||||
namingTemplate: z.string(),
|
||||
proxiedLock: z.boolean(),
|
||||
cloudflareConfigured: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type AppSettings = z.infer<typeof appSettingsSchema>;
|
||||
|
||||
Reference in New Issue
Block a user