From 6d8379501caf263fa2def609c91601d8b4bb4298 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 7 May 2026 13:35:41 +0700 Subject: [PATCH] refactor: replace custom API fetch logic with requestJson utility across multiple pages Updated the API fetching mechanism in various components to utilize the new requestJson function for improved consistency and error handling. This change affects the alerts, dashboard, data collection, filters, gre, network map, probes, recursive routes, route optimizer, servers, settings, traffic, and uptime pages. --- app/(main)/alerts/page.tsx | 13 +- app/(main)/dashboard/page.tsx | 19 +- app/(main)/data-collection/page.tsx | 19 +- app/(main)/filters/page.tsx | 12 +- app/(main)/gre/page.tsx | 12 +- app/(main)/network-map/page.tsx | 12 +- app/(main)/probes/page.tsx | 12 +- app/(main)/recursive-routes/page.tsx | 19 +- app/(main)/route-optimizer/page.tsx | 19 +- app/(main)/servers/page.tsx | 131 +++------- app/(main)/settings/page.tsx | 19 +- app/(main)/traffic/page.tsx | 19 +- app/(main)/uptime/page.tsx | 19 +- .../modules/servers/mapper/servers-mapper.ts | 61 +++++ .../servers/repository/servers-repository.ts | 53 ++++ .../servers/service/servers-service.ts | 55 +++++ backend/src/routes/alerts.ts | 96 +------- backend/src/routes/bgp.ts | 8 +- backend/src/routes/exec.ts | 5 +- backend/src/routes/ospf.ts | 11 +- backend/src/routes/servers.ts | 197 +++------------ backend/src/types/server.ts | 134 +++------- backend/tsconfig.json | 2 +- docs/refactor-baseline.md | 39 +++ entities/server/model/mappers.ts | 28 +++ lib/route-optimizer-data.ts | 3 +- lib/scheduler-run-snapshot.ts | 2 + packages/contracts/package.json | 17 ++ packages/contracts/src/alerts.d.ts | 204 ++++++++++++++++ packages/contracts/src/alerts.d.ts.map | 1 + packages/contracts/src/alerts.js | 71 ++++++ packages/contracts/src/alerts.js.map | 1 + packages/contracts/src/alerts.ts | 84 +++++++ packages/contracts/src/index.ts | 2 + packages/contracts/src/servers.d.ts | 228 ++++++++++++++++++ packages/contracts/src/servers.d.ts.map | 1 + packages/contracts/src/servers.js | 95 ++++++++ packages/contracts/src/servers.js.map | 1 + packages/contracts/src/servers.ts | 114 +++++++++ packages/contracts/tsconfig.json | 19 ++ shared/api/http-client.ts | 45 ++++ shared/api/servers.ts | 65 +++++ 42 files changed, 1336 insertions(+), 631 deletions(-) create mode 100644 backend/src/modules/servers/mapper/servers-mapper.ts create mode 100644 backend/src/modules/servers/repository/servers-repository.ts create mode 100644 backend/src/modules/servers/service/servers-service.ts create mode 100644 docs/refactor-baseline.md create mode 100644 entities/server/model/mappers.ts create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/alerts.d.ts create mode 100644 packages/contracts/src/alerts.d.ts.map create mode 100644 packages/contracts/src/alerts.js create mode 100644 packages/contracts/src/alerts.js.map create mode 100644 packages/contracts/src/alerts.ts create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/src/servers.d.ts create mode 100644 packages/contracts/src/servers.d.ts.map create mode 100644 packages/contracts/src/servers.js create mode 100644 packages/contracts/src/servers.js.map create mode 100644 packages/contracts/src/servers.ts create mode 100644 packages/contracts/tsconfig.json create mode 100644 shared/api/http-client.ts create mode 100644 shared/api/servers.ts diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index cb24415..b20a8aa 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -28,6 +28,7 @@ import { } from "lucide-react" import { Flag, countryName } from "@/components/flag" import { cn } from "@/lib/utils" +import { requestJson } from "@/shared/api/http-client" // ─── types ──────────────────────────────────────────────────────────────────── @@ -165,17 +166,7 @@ interface BgpSessionListRow { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string } - throw new Error(err.error ?? res.statusText) - } - if (res.status === 204) return undefined as T - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index fe9a825..776acd3 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -24,27 +24,12 @@ import { Flag } from "@/components/flag" import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react" import { Button, buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { requestJson } from "@/shared/api/http-client" import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency" function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - let message = res.statusText - try { - const err = await res.json() as { error?: string } - message = err.error ?? message - } catch { - const text = await res.text().catch(() => "") - if (text) message = text - } - throw new Error(message) - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index d0e99a0..d66e701 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -23,6 +23,7 @@ import { type SchedulerRunRowDto, type UptimeSettingsDto, } from "@/lib/scheduler-settings" +import { requestJson } from "@/shared/api/http-client" import { parseSchedulerRunSnapshot, type AlertEngineRuleDiagSnapshot, @@ -49,23 +50,7 @@ import { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - let message = res.statusText - try { - const err = (await res.json()) as { error?: string } - message = err.error ?? message - } catch { - const text = await res.text().catch(() => "") - if (text) message = text - } - throw new Error(message || "Ошибка запроса") - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index 9a88f4f..4e9e948 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" +import { requestJson } from "@/shared/api/http-client" import { servers, greTunnels, serverFilterRulesets, filters as mockFiltersCatalog, type FilterRule, type ServerFilterRuleset, type Server, type GreTunnel, @@ -1491,16 +1492,7 @@ function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!finalRes.ok) { - const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string } - throw new Error(err.error ?? finalRes.statusText) - } - return finalRes.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index 9291666..eeab614 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -5,6 +5,7 @@ import { PageHeader } from "@/components/page-header" import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data" import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { cn } from "@/lib/utils" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -210,16 +211,7 @@ interface GreTunnelsApiResponse { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!finalRes.ok) { - const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string } - throw new Error(err.error ?? finalRes.statusText) - } - return finalRes.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index f92d861..d7c7715 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -11,6 +11,7 @@ import { type ServerType, } from "@/lib/data" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { buildGreMapEdges, buildServerResourceMap, @@ -175,16 +176,7 @@ function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string } - throw new Error(err.error ?? res.statusText) - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx index 9686f70..5accfdc 100644 --- a/app/(main)/probes/page.tsx +++ b/app/(main)/probes/page.tsx @@ -14,6 +14,7 @@ import { LoaderCircleIcon, AlertCircleIcon, } from "lucide-react" import { cn } from "@/lib/utils" +import { requestJson } from "@/shared/api/http-client" // ─── types ──────────────────────────────────────────────────────────────────── @@ -66,16 +67,7 @@ const TOOL_META: Record(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!finalRes.ok) { - const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string } - throw new Error(err.error ?? finalRes.statusText) - } - return finalRes.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/recursive-routes/page.tsx b/app/(main)/recursive-routes/page.tsx index f7f2f7d..34c078e 100644 --- a/app/(main)/recursive-routes/page.tsx +++ b/app/(main)/recursive-routes/page.tsx @@ -12,6 +12,7 @@ import { useDataSource } from "@/lib/data-source" import { cn } from "@/lib/utils" import { servers as mockServers, type Server } from "@/lib/data" import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react" +import { requestJson } from "@/shared/api/http-client" interface BackendServer { id: number @@ -91,23 +92,7 @@ interface RouteGroup { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - let message = res.statusText - try { - const err = await res.json() as { error?: string } - message = err.error ?? message - } catch { - const text = await res.text().catch(() => "") - if (text) message = text - } - throw new Error(message) - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index bdf8a37..8c56f2b 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" +import { requestJson } from "@/shared/api/http-client" import { servers } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { @@ -40,23 +41,7 @@ import { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - let message = res.statusText - try { - const err = await res.json() as { error?: string } - message = err.error ?? message - } catch { - const text = await res.text().catch(() => "") - if (text) message = text - } - throw new Error(message) - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx index 74a7628..97aad9d 100644 --- a/app/(main)/servers/page.tsx +++ b/app/(main)/servers/page.tsx @@ -5,71 +5,21 @@ import { PageHeader } from "@/components/page-header" import { StatusBadge } from "@/components/status-badge" import { servers as initialServers } from "@/lib/data" import type { ServerType, Server, WanUplink } from "@/lib/data" +import type { ServerCreate, ServerUpdate } from "@/packages/contracts/src/servers" +import { toFrontendServer } from "@/entities/server/model/mappers" +import { + createServer, + deleteServer, + getServer, + listServers, + pollServer, + testServerConnection, + updateServer, +} from "@/shared/api/servers" // ─── Backend integration ────────────────────────────────────────────────────── import { useDataSource } from "@/lib/data-source" - -interface BackendServer { - id: number; name: string; host: string; port: number - useSsl: boolean; verifySsl: boolean; username: string; password: string - type: ServerType; site: string; country: string; asn: string - comment: string; enabled: boolean - lanSubnet: string - wanUplinks: WanUplink[] - status: "online" | "offline" | null; latency: number | null - os: string | null; model: string | null; uptime: string | null - cpuLoad: number | null; freeMemory: number | null; totalMemory: number | null - identityName: string | null - sessions: number; polledAt: string | null - createdAt: string; updatedAt: string -} - -function toFrontend(s: BackendServer): Server { - return { - id: String(s.id), - name: s.name || s.host, - host: s.host, - type: s.type, - site: s.site, - country: s.country, - asn: s.asn, - model: s.model ?? "—", - os: s.os ?? "—", - enabled: s.enabled, - status: s.status ?? "offline", - latency: s.latency != null ? Math.round(s.latency) : null, - sessions: s.sessions ?? 0, - comment: s.comment || undefined, - lanSubnet: s.lanSubnet || undefined, - wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined, - // carry extra fields needed for expanded view - uptime: s.uptime ?? undefined, - cpuLoad: s.cpuLoad ?? undefined, - freeMemory: s.freeMemory ?? undefined, - totalMemory: s.totalMemory ?? undefined, - polledAt: s.polledAt ?? undefined, - } -} - -function makeApiFetch(backendUrl: string) { - return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body - ? { "Content-Type": "application/json" } - : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string } - throw new Error(err.error ?? res.statusText) - } - // 204 No Content - if (res.status === 204) return undefined as T - return res.json() as Promise - } -} import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { Card, CardContent } from "@/components/ui/card" @@ -373,7 +323,6 @@ type SheetMode = "add" | "edit" export default function ServersPage() { const { mode, backendUrl, backendStatus } = useDataSource() const isLive = mode === "live" && backendStatus === true - const apiFetch = makeApiFetch(backendUrl) const [serverList, setServerList] = useState(initialServers) const [_backendOk, setBackendOk] = useState(false) @@ -402,10 +351,10 @@ export default function ServersPage() { }) return } - apiFetch("/api/servers") + listServers(backendUrl) .then(data => { setBackendOk(true) - setServerList(data.map(toFrontend)) + setServerList(data.map(toFrontendServer)) }) .catch(() => { setBackendOk(false) @@ -437,7 +386,7 @@ export default function ServersPage() { // Fetch full server details (including credentials) from backend if (isLive) { try { - const full = await apiFetch(`/api/servers/${s.id}`) + const full = await getServer(backendUrl, s.id) setForm(prev => ({ ...prev, username: full.username ?? "", @@ -458,7 +407,7 @@ export default function ServersPage() { } async function handleSave() { - const payload = { + const payload: ServerCreate = { host: form.host, port: Number(form.port) || (form.proto === "https" ? 443 : 80), username: form.username, @@ -479,17 +428,12 @@ export default function ServersPage() { if (isLive) { try { if (sheetMode === "edit" && editingId) { - const updated = await apiFetch( - `/api/servers/${editingId}`, - { method: "PUT", body: JSON.stringify(payload) }, - ) - setServerList(list => list.map(s => s.id === editingId ? toFrontend(updated) : s)) + const updatedPayload: ServerUpdate = payload + const updated = await updateServer(backendUrl, editingId, updatedPayload) + setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s)) } else { - const created = await apiFetch( - "/api/servers", - { method: "POST", body: JSON.stringify(payload) }, - ) - setServerList(list => [...list, toFrontend(created)]) + const created = await createServer(backendUrl, payload) + setServerList(list => [...list, toFrontendServer(created)]) } } catch (e) { console.error("Ошибка сохранения:", e) @@ -540,7 +484,7 @@ export default function ServersPage() { async function handleDelete(id: string) { if (isLive) { try { - await apiFetch(`/api/servers/${id}`, { method: "DELETE" }) + await deleteServer(backendUrl, id) } catch (e) { console.error("Ошибка удаления:", e) } @@ -552,10 +496,10 @@ export default function ServersPage() { if (!isLive) return setPollingIds(p => new Set(p).add(id)) try { - await apiFetch(`/api/servers/${id}/poll`, { method: "POST" }) + await pollServer(backendUrl, id) // Reload full list to get updated status/version - const data = await apiFetch("/api/servers") - setServerList(data.map(toFrontend)) + const data = await listServers(backendUrl) + setServerList(data.map(toFrontendServer)) } catch (e) { console.error("Poll error:", e) } finally { @@ -568,9 +512,9 @@ export default function ServersPage() { setPollAllBusy(true) try { const ids = serverList.map(s => s.id) - await Promise.all(ids.map(id => apiFetch(`/api/servers/${id}/poll`, { method: "POST" }).catch(() => {}))) - const data = await apiFetch("/api/servers") - setServerList(data.map(toFrontend)) + await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {}))) + const data = await listServers(backendUrl) + setServerList(data.map(toFrontendServer)) } finally { setPollAllBusy(false) } @@ -582,20 +526,15 @@ export default function ServersPage() { } setTestState("testing"); setTestMsg("") try { - const res = await fetch(backendUrl.replace(/\/$/, "") + "/api/servers/test-connection", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - host: form.host, - port: Number(form.port) || (form.proto === "https" ? 443 : 80), - useSsl: form.proto === "https", - verifySsl: form.verifySsl, - apiPath: form.apiPath || "/rest", - username: form.username, - password: form.password, - }), + const data = await testServerConnection(backendUrl, { + host: form.host, + port: Number(form.port) || (form.proto === "https" ? 443 : 80), + useSsl: form.proto === "https", + verifySsl: form.verifySsl, + apiPath: form.apiPath || "/rest", + username: form.username, + password: form.password, }) - const data = await res.json() as { success: boolean; message: string } if (data.success) { setTestState("ok"); setTestMsg(data.message) } else { diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index 759b923..72b6804 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -24,6 +24,7 @@ import { ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon, } from "lucide-react" import { cn } from "@/lib/utils" +import { requestJson } from "@/shared/api/http-client" // ─── types ──────────────────────────────────────────────────────────────────── @@ -152,23 +153,7 @@ type NavSection = typeof SECTIONS_NAV[number] function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - let message = res.statusText - try { - const err = await res.json() as { error?: string } - message = err.error ?? message - } catch { - const text = await res.text().catch(() => "") - if (text) message = text - } - throw new Error(message || "Ошибка запроса") - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 96eb728..82d1df2 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" // ─── helpers ────────────────────────────────────────────────────────────────── @@ -126,23 +127,7 @@ interface LiveTrafficInterface { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - let message = res.statusText - try { - const err = await res.json() as { error?: string } - message = err.error ?? message - } catch { - const text = await res.text().catch(() => "") - if (text) message = text - } - throw new Error(message) - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 7c2d2c0..8dff44c 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils" import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data" import type { PingProbe } from "@/lib/data" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { RefreshCwIcon, PlusIcon, SearchIcon, XIcon, ChevronDownIcon, ChevronRightIcon, @@ -63,23 +64,7 @@ function mockProbesWithSavedStars(base: PingProbe[]): PingProbe[] { function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { - const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} - const res = await fetch(backendUrl.replace(/\/$/, "") + path, { - ...init, - headers: { ...headers, ...(init?.headers ?? {}) }, - }) - if (!res.ok) { - let message = res.statusText - try { - const err = await res.json() as { error?: string } - message = err.error ?? message - } catch { - const text = await res.text().catch(() => "") - if (text) message = text - } - throw new Error(message) - } - return res.json() as Promise + return requestJson(backendUrl, path, init) } } diff --git a/backend/src/modules/servers/mapper/servers-mapper.ts b/backend/src/modules/servers/mapper/servers-mapper.ts new file mode 100644 index 0000000..4cc84d5 --- /dev/null +++ b/backend/src/modules/servers/mapper/servers-mapper.ts @@ -0,0 +1,61 @@ +import type { ServerRead, WanUplinkRead } from "../../../types/server.js" +import type { ServerRow, SnapshotRow } from "../repository/servers-repository.js" + +function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] { + if (raw == null || raw === "") return [] + try { + const data = JSON.parse(raw) as unknown + if (!Array.isArray(data)) return [] + const out: WanUplinkRead[] = [] + for (const row of data) { + if (typeof row !== "object" || row === null) continue + const r = row as Record + out.push({ + id: typeof r.id === "string" ? r.id : "", + name: typeof r.name === "string" ? r.name : "", + isp: typeof r.isp === "string" ? r.isp : "", + iface: typeof r.iface === "string" ? r.iface : "", + ip: typeof r.ip === "string" ? r.ip : "", + maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0, + maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0, + }) + } + return out + } catch { + return [] + } +} + +export function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead { + return { + id: server.id, + name: server.name || server.host, + host: server.host, + port: server.port, + useSsl: server.useSsl, + verifySsl: server.verifySsl, + username: server.username, + password: server.password, + type: server.type, + site: server.site, + country: server.country, + asn: server.asn, + comment: server.comment, + enabled: server.enabled, + lanSubnet: server.lanSubnet ?? "", + wanUplinks: parseWanUplinksJson(server.wanUplinks), + createdAt: server.createdAt, + updatedAt: server.updatedAt, + status: snap ? snap.status : null, + latency: snap?.latencyMs ?? null, + os: snap?.rosVersion ?? null, + model: snap?.boardName ?? null, + uptime: snap?.uptime ?? null, + cpuLoad: snap?.cpuLoad ?? null, + freeMemory: snap?.freeMemory ?? null, + totalMemory: snap?.totalMemory ?? null, + identityName: snap?.identityName ?? null, + sessions: 0, + polledAt: snap?.polledAt ?? null, + } +} diff --git a/backend/src/modules/servers/repository/servers-repository.ts b/backend/src/modules/servers/repository/servers-repository.ts new file mode 100644 index 0000000..1bd9f11 --- /dev/null +++ b/backend/src/modules/servers/repository/servers-repository.ts @@ -0,0 +1,53 @@ +import { desc, eq } from "drizzle-orm" +import { db } from "../../../db/index.js" +import { serverSnapshots, servers } from "../../../db/schema.js" + +export type ServerRow = typeof servers.$inferSelect +export type SnapshotRow = typeof serverSnapshots.$inferSelect + +export function listServerRows(): ServerRow[] { + return db.select().from(servers).all() +} + +export function getServerRowById(id: number): ServerRow | undefined { + return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] +} + +export function createServerRow( + values: Omit, +): ServerRow { + const [inserted] = db.insert(servers).values(values).returning().all() + return inserted +} + +export function updateServerRowById( + id: number, + values: Partial, +): ServerRow { + const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all() + return updated +} + +export function deleteServerRowById(id: number): void { + db.delete(servers).where(eq(servers.id, id)).run() +} + +export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] { + return db + .select() + .from(serverSnapshots) + .where(eq(serverSnapshots.serverId, serverId)) + .orderBy(desc(serverSnapshots.polledAt)) + .limit(limit) + .all() +} + +export function getLatestSnapshot(serverId: number): SnapshotRow | undefined { + return db + .select() + .from(serverSnapshots) + .where(eq(serverSnapshots.serverId, serverId)) + .orderBy(desc(serverSnapshots.polledAt)) + .limit(1) + .all()[0] +} diff --git a/backend/src/modules/servers/service/servers-service.ts b/backend/src/modules/servers/service/servers-service.ts new file mode 100644 index 0000000..e78bea3 --- /dev/null +++ b/backend/src/modules/servers/service/servers-service.ts @@ -0,0 +1,55 @@ +import { toSnapshotRead } from "../../../services/poller.js" +import { + createServerRow, + deleteServerRowById, + getLatestSnapshot, + getServerRowById, + listServerRows, + listSnapshotsByServerId, + updateServerRowById, +} from "../repository/servers-repository.js" +import { toServerRead } from "../mapper/servers-mapper.js" +import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js" + +export function listServersRead(): ServerRead[] { + return listServerRows().map((server) => toServerRead(server, getLatestSnapshot(server.id))) +} + +export function getServerReadById(id: number): ServerRead | undefined { + const row = getServerRowById(id) + if (!row) return undefined + return toServerRead(row, getLatestSnapshot(row.id)) +} + +export function createServer(input: ServerCreate): ServerRead { + const now = new Date().toISOString() + const { wanUplinks, ...rest } = input + const inserted = createServerRow({ + ...rest, + wanUplinks: JSON.stringify(wanUplinks ?? []), + createdAt: now, + updatedAt: now, + }) + return toServerRead(inserted, undefined) +} + +export function updateServer(id: number, input: ServerUpdate): ServerRead { + const { wanUplinks, ...rest } = input + const setPayload: Record = { updatedAt: new Date().toISOString() } + for (const [k, v] of Object.entries(rest)) { + if (v !== undefined) setPayload[k] = v + } + if (wanUplinks !== undefined) { + setPayload.wanUplinks = JSON.stringify(wanUplinks) + } + const updated = updateServerRowById(id, setPayload as never) + return toServerRead(updated, getLatestSnapshot(updated.id)) +} + +export function deleteServer(id: number): void { + deleteServerRowById(id) +} + +export function listServerSnapshots(id: number, limit: number): SnapshotRead[] { + return listSnapshotsByServerId(id, limit).map(toSnapshotRead) +} diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts index f66546b..1133b69 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -1,5 +1,4 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" -import { z } from "zod" import { getAlertsMeta, getTelegramBotToken, @@ -13,90 +12,11 @@ import { sendTelegramAlertMessage, updateTelegramSettings, } from "../services/alerts-service.js" - -const AlertTypeSchema = z.enum([ - "gre-tunnel", - "bgp-peer", - "bgp-prefix", - "gre-client", - "server", - "rtt", - "loss", - "traffic", -]) -const AlertSeveritySchema = z.enum(["critical", "warning", "info"]) -const AlertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"]) -const RecoveryModeSchema = z.enum(["always", "never", "conditional"]) - -const AlertGroupBodySchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - combineMode: z.enum(["any", "all"]), - enabled: z.boolean(), - cooldownOverride: z.union([AlertCooldownSchema, z.null()]), -}) - -const AlertRuleBodySchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - type: AlertTypeSchema, - target: z.string().optional(), - targets: z.array(z.string().min(1)).optional(), - groupId: z.string().nullable().optional(), - /** Сводка; если переданы `conditions`, может дублировать первую строку */ - condition: z.string().optional(), - /** Несколько условий (OR); приоритет над одной строкой `condition` */ - conditions: z.array(z.string().min(1)).optional(), - severity: AlertSeveritySchema, - enabled: z.boolean(), - cooldown: AlertCooldownSchema, - /** Секунды подтверждения стабильности; 0 / null / не передано — выкл. */ - confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(), - recoveryMode: RecoveryModeSchema.optional(), - recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(), - chatId: z.string(), - }) - .refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), { - message: "Нужен хотя бы один объект: targets или target", - path: ["targets"], - }) - .refine( - (r) => - (r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()), - { message: "Нужно хотя бы одно условие: conditions или condition", path: ["conditions"] }, - ) - -const PutRulesSchema = z.object({ - rules: z.array(AlertRuleBodySchema), - groups: z.array(AlertGroupBodySchema).optional(), -}) - -const PutTelegramSchema = z.object({ - /** undefined — не менять; null или "" — очистить токен */ - token: z.union([z.string(), z.null()]).optional(), - chatId: z.string().optional(), - /** undefined — не менять; null — сбросить (общий чат супергруппы) */ - messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(), -}) - -const RulePreviewTelegramSchema = z.object({ - name: z.string().min(1), - targets: z.array(z.string().min(1)).min(1), - conditionLine: z.string().min(1), - severity: AlertSeveritySchema, - cooldown: AlertCooldownSchema, -}) - -const TestTelegramSchema = z.object({ - /** Если не передан — взять сохранённый токен из БД */ - token: z.string().optional(), - chatId: z.string().optional(), - /** Если не передан — из БД; для проверки черновика до сохранения */ - messageThreadId: z.coerce.number().int().positive().optional(), - /** Если задан — вместо короткого текста отправляется предпросмотр правила (создание / правка). */ - rulePreview: RulePreviewTelegramSchema.optional(), -}) +import { + putRulesSchema, + putTelegramSchema, + testTelegramSchema, +} from "../../../packages/contracts/dist/alerts.js" const alertsRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/alerts", async (_req, reply) => { @@ -112,7 +32,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => { }) app.put("/alerts/rules", async (req, reply) => { - const parsed = PutRulesSchema.safeParse(req.body) + const parsed = putRulesSchema.safeParse(req.body) if (!parsed.success) { return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) } @@ -152,7 +72,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => { }) app.put("/alerts/telegram", async (req, reply) => { - const parsed = PutTelegramSchema.safeParse(req.body) + const parsed = putTelegramSchema.safeParse(req.body) if (!parsed.success) { return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) } @@ -166,7 +86,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => { }) app.post("/alerts/telegram/test", async (req, reply) => { - const parsed = TestTelegramSchema.safeParse(req.body ?? {}) + const parsed = testTelegramSchema.safeParse(req.body ?? {}) if (!parsed.success) { return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) } diff --git a/backend/src/routes/bgp.ts b/backend/src/routes/bgp.ts index d6fbcfc..b4b3d28 100644 --- a/backend/src/routes/bgp.ts +++ b/backend/src/routes/bgp.ts @@ -4,14 +4,15 @@ import { db } from "../db/index.js" import { servers } from "../db/schema.js" import { parseBgpSessions } from "../services/bgp-parse-sessions.js" import { MikrotikClient } from "../services/mikrotik.js" -import { ServerIdParamSchema } from "../types/server.js" +import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import type { BgpSessionRead } from "../types/server.js" const bgpRoutes: FastifyPluginAsyncZod = async (app) => { // GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug) app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { - const server = db.select().from(servers).where(eq(servers.id, req.params.id)).limit(1).all()[0] + const params = req.params as ServerIdParams + const server = db.select().from(servers).where(eq(servers.id, params.id)).limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) try { const client = MikrotikClient.fromServer(server) @@ -44,9 +45,10 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => { // GET /api/servers/:id/bgp/sessions — single server app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { + const params = req.params as ServerIdParams const server = db .select().from(servers) - .where(eq(servers.id, req.params.id)) + .where(eq(servers.id, params.id)) .limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) diff --git a/backend/src/routes/exec.ts b/backend/src/routes/exec.ts index ef9f333..acfbd3f 100644 --- a/backend/src/routes/exec.ts +++ b/backend/src/routes/exec.ts @@ -4,7 +4,7 @@ import { z } from "zod" import { db } from "../db/index.js" import { servers } from "../db/schema.js" import { MikrotikClient } from "../services/mikrotik.js" -import { ServerIdParamSchema } from "../types/server.js" +import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import type { RosIpAddress, RosInterface, RosResource, RosIdentity, RosIpRoute, RosFirewallFilter, RosLogEntry, RosBgpSession, RosPingResult, @@ -313,9 +313,10 @@ const execRoutes: FastifyPluginAsyncZod = async (app) => { "/servers/:id/exec", { schema: { params: ServerIdParamSchema, body: ExecBodySchema } }, async (req, reply) => { + const params = req.params as ServerIdParams const server = db .select().from(servers) - .where(eq(servers.id, req.params.id)) + .where(eq(servers.id, params.id)) .limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index d02b693..c923c7a 100644 --- a/backend/src/routes/ospf.ts +++ b/backend/src/routes/ospf.ts @@ -3,7 +3,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js" import { MikrotikClient } from "../services/mikrotik.js" -import { ServerIdParamSchema } from "../types/server.js" +import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import type { RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, RosBfdSession, @@ -630,9 +630,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => { // GET /api/servers/:id/ospf — single server OSPF + BFD data app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { + const params = req.params as ServerIdParams const server = db .select().from(servers) - .where(eq(servers.id, req.params.id)) + .where(eq(servers.id, params.id)) .limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) @@ -657,9 +658,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => { "/servers/:id/ospf/optimize/preview", { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, async (req, reply) => { + const params = req.params as ServerIdParams const server = db .select().from(servers) - .where(eq(servers.id, req.params.id)) + .where(eq(servers.id, params.id)) .limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) @@ -688,9 +690,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => { "/servers/:id/ospf/optimize", { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, async (req, reply) => { + const params = req.params as ServerIdParams const server = db .select().from(servers) - .where(eq(servers.id, req.params.id)) + .where(eq(servers.id, params.id)) .limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) diff --git a/backend/src/routes/servers.ts b/backend/src/routes/servers.ts index 2a20ab8..cd3656e 100644 --- a/backend/src/routes/servers.ts +++ b/backend/src/routes/servers.ts @@ -1,95 +1,28 @@ -import { desc, eq } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" -import { db } from "../db/index.js" -import { servers, serverSnapshots } from "../db/schema.js" -import type { ServerRead, WanUplinkRead } from "../types/server.js" import { ServerCreateSchema, ServerUpdateSchema, ServerIdParamSchema, SnapshotsQuerySchema, TestConnectionSchema, + type ServerCreateRequest, + type ServerIdParams, + type ServerUpdateRequest, + type SnapshotsQuery, + type TestConnectionRequest, } from "../types/server.js" -import { pollServer, toSnapshotRead } from "../services/poller.js" +import { pollServer } from "../services/poller.js" import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js" - -// ── helpers ──────────────────────────────────────────────────────────────────── - -type ServerRow = typeof servers.$inferSelect -type SnapshotRow = typeof serverSnapshots.$inferSelect - -function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] { - if (raw == null || raw === "") return [] - try { - const data = JSON.parse(raw) as unknown - if (!Array.isArray(data)) return [] - const out: WanUplinkRead[] = [] - for (const row of data) { - if (typeof row !== "object" || row === null) continue - const r = row as Record - out.push({ - id: typeof r.id === "string" ? r.id : "", - name: typeof r.name === "string" ? r.name : "", - isp: typeof r.isp === "string" ? r.isp : "", - iface: typeof r.iface === "string" ? r.iface : "", - ip: typeof r.ip === "string" ? r.ip : "", - maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0, - maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0, - }) - } - return out - } catch { - return [] - } -} - -/** Merge a server row with its latest snapshot into the frontend-compatible shape */ -function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead { - return { - id: server.id, - name: server.name || server.host, - host: server.host, - port: server.port, - useSsl: server.useSsl, - verifySsl: server.verifySsl, - username: server.username, - password: server.password, - type: server.type, - site: server.site, - country: server.country, - asn: server.asn, - comment: server.comment, - enabled: server.enabled, - lanSubnet: server.lanSubnet ?? "", - wanUplinks: parseWanUplinksJson(server.wanUplinks), - createdAt: server.createdAt, - updatedAt: server.updatedAt, - // snapshot fields (null if never polled) - status: snap ? snap.status : null, - latency: snap?.latencyMs ?? null, - os: snap?.rosVersion ?? null, - model: snap?.boardName ?? null, - uptime: snap?.uptime ?? null, - cpuLoad: snap?.cpuLoad ?? null, - freeMemory: snap?.freeMemory ?? null, - totalMemory: snap?.totalMemory ?? null, - identityName: snap?.identityName ?? null, - sessions: 0, - polledAt: snap?.polledAt ?? null, - } -} - -/** Get latest snapshot for a single server */ -function getLatestSnapshot(serverId: number): SnapshotRow | undefined { - return db - .select() - .from(serverSnapshots) - .where(eq(serverSnapshots.serverId, serverId)) - .orderBy(desc(serverSnapshots.polledAt)) - .limit(1) - .all()[0] -} +import { + createServer, + deleteServer, + getServerReadById, + listServerSnapshots, + listServersRead, + updateServer, +} from "../modules/servers/service/servers-service.js" +import { getServerRowById } from "../modules/servers/repository/servers-repository.js" // ── plugin ───────────────────────────────────────────────────────────────────── @@ -97,7 +30,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => { // POST /api/servers/test-connection — check credentials before saving app.post("/test-connection", { schema: { body: TestConnectionSchema } }, async (req, reply) => { - const { host, port, useSsl, verifySsl, apiPath, username, password } = req.body + const { host, port, useSsl, verifySsl, apiPath, username, password } = req.body as TestConnectionRequest const client = new MikrotikClient({ host, port, useSsl, verifySsl, apiPath, username, password }) try { @@ -144,17 +77,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => { // GET /api/servers app.get("/", async (_req, reply) => { - const all = db.select().from(servers).all() - const result: ServerRead[] = all.map(s => toServerRead(s, getLatestSnapshot(s.id))) - return reply.send(result) + return reply.send(listServersRead()) }) // GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN) app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { - const server = db - .select().from(servers) - .where(eq(servers.id, req.params.id)) - .limit(1).all()[0] + const params = req.params as ServerIdParams + const server = getServerRowById(params.id) if (!server) return reply.status(404).send({ error: "Server not found" }) const ipv4 = await resolveRosSrcIpv4(server.host) @@ -163,30 +92,15 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => { // POST /api/servers app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => { - const now = new Date().toISOString() - const { wanUplinks, ...rest } = req.body - const [inserted] = db - .insert(servers) - .values({ - ...rest, - wanUplinks: JSON.stringify(wanUplinks ?? []), - createdAt: now, - updatedAt: now, - }) - .returning() - .all() - return reply.status(201).send(toServerRead(inserted, undefined)) + return reply.status(201).send(createServer(req.body as ServerCreateRequest)) }) // GET /api/servers/:id app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { - const server = db - .select().from(servers) - .where(eq(servers.id, req.params.id)) - .limit(1).all()[0] - + const params = req.params as ServerIdParams + const server = getServerReadById(params.id) if (!server) return reply.status(404).send({ error: "Server not found" }) - return reply.send(toServerRead(server, getLatestSnapshot(server.id))) + return reply.send(server) }) // PUT /api/servers/:id @@ -194,58 +108,30 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => { "/:id", { schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } }, async (req, reply) => { - const existing = db - .select().from(servers) - .where(eq(servers.id, req.params.id)) - .limit(1).all()[0] - + const params = req.params as ServerIdParams + const existing = getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) - - const body = req.body - const { wanUplinks, ...rest } = body - const setPayload: Record = { updatedAt: new Date().toISOString() } - for (const [k, v] of Object.entries(rest)) { - if (v !== undefined) setPayload[k] = v - } - if (wanUplinks !== undefined) { - setPayload.wanUplinks = JSON.stringify(wanUplinks) - } - - const [updated] = db - .update(servers) - .set(setPayload as Partial) - .where(eq(servers.id, req.params.id)) - .returning() - .all() - - return reply.send(toServerRead(updated, getLatestSnapshot(updated.id))) + return reply.send(updateServer(params.id, req.body as ServerUpdateRequest)) }, ) // DELETE /api/servers/:id app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { - const existing = db - .select().from(servers) - .where(eq(servers.id, req.params.id)) - .limit(1).all()[0] - + const params = req.params as ServerIdParams + const existing = getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) - - db.delete(servers).where(eq(servers.id, req.params.id)).run() + deleteServer(params.id) return reply.status(204).send() }) // POST /api/servers/:id/poll app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { - const existing = db - .select().from(servers) - .where(eq(servers.id, req.params.id)) - .limit(1).all()[0] - + const params = req.params as ServerIdParams + const existing = getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) try { - const snap = await pollServer(req.params.id) + const snap = await pollServer(params.id) return reply.send(snap) } catch (err) { return reply.status(500).send({ error: (err as Error).message }) @@ -257,22 +143,11 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => { "/:id/snapshots", { schema: { params: ServerIdParamSchema, querystring: SnapshotsQuerySchema } }, async (req, reply) => { - const existing = db - .select().from(servers) - .where(eq(servers.id, req.params.id)) - .limit(1).all()[0] - + const params = req.params as ServerIdParams + const query = req.query as SnapshotsQuery + const existing = getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) - - const snaps = db - .select() - .from(serverSnapshots) - .where(eq(serverSnapshots.serverId, req.params.id)) - .orderBy(desc(serverSnapshots.polledAt)) - .limit(req.query.limit) - .all() - - return reply.send(snaps.map(toSnapshotRead)) + return reply.send(listServerSnapshots(params.id, query.limit)) }, ) } diff --git a/backend/src/types/server.ts b/backend/src/types/server.ts index 312f4d4..33d5239 100644 --- a/backend/src/types/server.ts +++ b/backend/src/types/server.ts @@ -1,4 +1,16 @@ import { z } from "zod" +import { + serverCreateSchema, + serverIdParamSchema, + snapshotsQuerySchema, + serverUpdateSchema, + testConnectionSchema, + type ServerRead as ContractServerRead, + type SnapshotRead as ContractSnapshotRead, + type ServerCreate as ContractServerCreate, + type ServerUpdate as ContractServerUpdate, + type WanUplink as ContractWanUplinkRead, +} from "../../../packages/contracts/dist/servers.js" // ── RouterOS raw response types ──────────────────────────────────────────────── @@ -49,116 +61,28 @@ export interface RosIpAddress { // ── Zod schemas for API validation ──────────────────────────────────────────── -export const WanUplinkSchema = z.object({ - id: z.string(), - name: z.string(), - isp: z.string(), - iface: z.string(), - ip: z.string(), - maxDl: z.number(), - maxUl: z.number(), -}) +export const ServerCreateSchema = serverCreateSchema +export const ServerUpdateSchema = serverUpdateSchema +export const TestConnectionSchema = testConnectionSchema +export const ServerIdParamSchema = serverIdParamSchema +export const SnapshotsQuerySchema = snapshotsQuerySchema -export const ServerCreateSchema = z.object({ - host: z.string().min(1, "Host is required"), - port: z.number().int().positive().default(443), - username: z.string().default("admin"), - password: z.string().default(""), - useSsl: z.boolean().default(true), - verifySsl: z.boolean().default(false), - name: z.string().default(""), - type: z.enum(["jump-host", "exit-node", "home-router"]).default("home-router"), - site: z.string().default(""), - country: z.string().default(""), - asn: z.string().default(""), - comment: z.string().default(""), - enabled: z.boolean().default(true), - lanSubnet: z.string().default(""), - wanUplinks: z.array(WanUplinkSchema).default([]), -}) - -export const ServerUpdateSchema = ServerCreateSchema.partial().omit({ host: true }).extend({ - host: z.string().min(1).optional(), -}) - -export const TestConnectionSchema = z.object({ - host: z.string().min(1), - port: z.number().int().positive().default(443), - useSsl: z.boolean().default(true), - verifySsl: z.boolean().default(false), - apiPath: z.string().default("/rest"), - username: z.string().default("admin"), - password: z.string().default(""), -}) - -export const ServerIdParamSchema = z.object({ - id: z.coerce.number().int().positive(), -}) - -export const SnapshotsQuerySchema = z.object({ - limit: z.coerce.number().int().positive().max(500).default(50), -}) +// Request payload types for route handlers. +// (Type-provider-zod in this repo sometimes falls back to `unknown` during cross-package schema wiring.) +export type ServerIdParams = z.infer +export type TestConnectionRequest = z.infer +export type SnapshotsQuery = z.infer +export type ServerCreateRequest = z.infer +export type ServerUpdateRequest = z.infer // ── Response types (what the API returns) ───────────────────────────────────── /** Flat server response — mirrors the frontend's Server interface from lib/data.ts */ -export interface WanUplinkRead { - id: string - name: string - isp: string - iface: string - ip: string - maxDl: number - maxUl: number -} - -export interface ServerRead { - id: number - name: string - host: string - port: number - useSsl: boolean - verifySsl: boolean - username: string - password: string - type: "jump-host" | "exit-node" | "home-router" - site: string - country: string - asn: string - comment: string - enabled: boolean - lanSubnet: string - wanUplinks: WanUplinkRead[] - createdAt: string - updatedAt: string - // from latest snapshot (null if never polled) - status: "online" | "offline" | null - latency: number | null // ms - os: string | null // RouterOS version - model: string | null // board-name - uptime: string | null - cpuLoad: number | null - freeMemory: number | null - totalMemory: number | null - identityName: string | null - sessions: number // always 0 for now - polledAt: string | null -} - -export interface SnapshotRead { - id: number - serverId: number - polledAt: string - status: "online" | "offline" - latencyMs: number | null - rosVersion: string | null - boardName: string | null - uptime: string | null - cpuLoad: number | null - freeMemory: number | null - totalMemory: number | null - identityName: string | null -} +export type WanUplinkRead = ContractWanUplinkRead +export type ServerCreate = ContractServerCreate +export type ServerUpdate = ContractServerUpdate +export type ServerRead = ContractServerRead +export type SnapshotRead = ContractSnapshotRead // ── BGP types ──────────────────────────────────────────────────────────────── diff --git a/backend/tsconfig.json b/backend/tsconfig.json index dabe2fc..9813a2a 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "dist", - "rootDir": "src", + "rootDir": "..", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/docs/refactor-baseline.md b/docs/refactor-baseline.md new file mode 100644 index 0000000..c7d00cf --- /dev/null +++ b/docs/refactor-baseline.md @@ -0,0 +1,39 @@ +# Refactor Baseline + +## Проверки + +- Frontend lint: `npm run lint` (root) — failed (`20 errors`, `10 warnings`). +- Backend build: `npm run build` (`backend`) — passed. +- Backend smoke: `npm run test:alert-engine` (`backend`) — passed. + +## Критичные baseline-проблемы lint + +- Массовые `react-hooks/set-state-in-effect` в крупных UI-экранах и `components`. +- `prefer-const` ошибки в backend сервисах. +- Накопленные предупреждения `exhaustive-deps` и `no-unused-vars`. + +## Архитектурные hotspots (для приоритетной миграции) + +- Frontend giant pages: + - `app/(main)/alerts/page.tsx` + - `app/(main)/uptime/page.tsx` + - `app/(main)/network-map/page.tsx` + - `app/(main)/filters/page.tsx` + - `app/(main)/servers/page.tsx` +- Backend mixed layers: + - `backend/src/routes/servers.ts` + - `backend/src/routes/uptime.ts` + - `backend/src/routes/ospf.ts` + - `backend/src/services/alerts-service.ts` + +## Дубли контрактов/типов + +- `backend/src/types/scheduler-run-snapshot.ts` и `lib/scheduler-run-snapshot.ts` +- `backend/src/types/server.ts` и локальные `Backend*` типы страниц + +## Инварианты рефакторинга + +- Не менять API shape endpoint-ов. +- Не менять бизнес-логику. +- Не менять поведение UI. +- Переход через параллельный новый слой и поэтапное переключение. diff --git a/entities/server/model/mappers.ts b/entities/server/model/mappers.ts new file mode 100644 index 0000000..689d856 --- /dev/null +++ b/entities/server/model/mappers.ts @@ -0,0 +1,28 @@ +import type { ServerRead } from "@/packages/contracts/src/servers" +import type { Server } from "@/lib/data" + +export function toFrontendServer(server: ServerRead): Server { + return { + id: String(server.id), + name: server.name || server.host, + host: server.host, + type: server.type, + site: server.site, + country: server.country, + asn: server.asn, + model: server.model ?? "—", + os: server.os ?? "—", + enabled: server.enabled, + status: server.status ?? "offline", + latency: server.latency != null ? Math.round(server.latency) : null, + sessions: server.sessions ?? 0, + comment: server.comment || undefined, + lanSubnet: server.lanSubnet || undefined, + wanUplinks: Array.isArray(server.wanUplinks) && server.wanUplinks.length ? server.wanUplinks : undefined, + uptime: server.uptime ?? undefined, + cpuLoad: server.cpuLoad ?? undefined, + freeMemory: server.freeMemory ?? undefined, + totalMemory: server.totalMemory ?? undefined, + polledAt: server.polledAt ?? undefined, + } +} diff --git a/lib/route-optimizer-data.ts b/lib/route-optimizer-data.ts index 52222c9..22fc540 100644 --- a/lib/route-optimizer-data.ts +++ b/lib/route-optimizer-data.ts @@ -388,7 +388,8 @@ export function buildLiveOptimizerData( } if (bestAssign) { - for (const wan of home.wans) out.set(wan.id, bestAssign.get(wan.id)) + const best: Map = bestAssign as Map + for (const wan of home.wans) out.set(wan.id, best.get(wan.id)) return out } diff --git a/lib/scheduler-run-snapshot.ts b/lib/scheduler-run-snapshot.ts index 3b392d9..0f4ffd7 100644 --- a/lib/scheduler-run-snapshot.ts +++ b/lib/scheduler-run-snapshot.ts @@ -25,6 +25,8 @@ export type AlertEngineRuleDiagSnapshot = { ruleId: string inGroup: boolean evalHit: boolean + hitTransition?: "problem" | "recovery" | "neutral" + hitMessage?: string stabilityOk: boolean cooldownOk: boolean telegramOk: boolean diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..e809a5c --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mmapp/contracts", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "exports": { + ".": "./dist/index.js", + "./servers": "./dist/servers.js", + "./alerts": "./dist/alerts.js" + }, + "dependencies": { + "zod": "^4.4.1" + } +} diff --git a/packages/contracts/src/alerts.d.ts b/packages/contracts/src/alerts.d.ts new file mode 100644 index 0000000..2010159 --- /dev/null +++ b/packages/contracts/src/alerts.d.ts @@ -0,0 +1,204 @@ +import { z } from "zod"; +export declare const alertTypeSchema: z.ZodEnum<{ + "gre-tunnel": "gre-tunnel"; + "bgp-peer": "bgp-peer"; + "bgp-prefix": "bgp-prefix"; + "gre-client": "gre-client"; + server: "server"; + rtt: "rtt"; + loss: "loss"; + traffic: "traffic"; +}>; +export declare const alertSeveritySchema: z.ZodEnum<{ + critical: "critical"; + warning: "warning"; + info: "info"; +}>; +export declare const alertCooldownSchema: z.ZodEnum<{ + "1\u043C": "1м"; + "5\u043C": "5м"; + "15\u043C": "15м"; + "1\u0447": "1ч"; + "4\u0447": "4ч"; + "24\u0447": "24ч"; +}>; +export declare const recoveryModeSchema: z.ZodEnum<{ + never: "never"; + always: "always"; + conditional: "conditional"; +}>; +export declare const combineModeSchema: z.ZodEnum<{ + any: "any"; + all: "all"; +}>; +export declare const alertGroupSchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + combineMode: z.ZodEnum<{ + any: "any"; + all: "all"; + }>; + enabled: z.ZodBoolean; + cooldownOverride: z.ZodUnion, z.ZodNull]>; +}, z.core.$strip>; +export declare const alertRuleSchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + type: z.ZodEnum<{ + "gre-tunnel": "gre-tunnel"; + "bgp-peer": "bgp-peer"; + "bgp-prefix": "bgp-prefix"; + "gre-client": "gre-client"; + server: "server"; + rtt: "rtt"; + loss: "loss"; + traffic: "traffic"; + }>; + target: z.ZodOptional; + targets: z.ZodOptional>; + groupId: z.ZodOptional>; + condition: z.ZodOptional; + conditions: z.ZodOptional>; + severity: z.ZodEnum<{ + critical: "critical"; + warning: "warning"; + info: "info"; + }>; + enabled: z.ZodBoolean; + cooldown: z.ZodEnum<{ + "1\u043C": "1м"; + "5\u043C": "5м"; + "15\u043C": "15м"; + "1\u0447": "1ч"; + "4\u0447": "4ч"; + "24\u0447": "24ч"; + }>; + confirmStabilitySec: z.ZodOptional>; + recoveryMode: z.ZodOptional>; + recoveryStabilitySec: z.ZodOptional>; + chatId: z.ZodString; +}, z.core.$strip>; +export declare const putRulesSchema: z.ZodObject<{ + rules: z.ZodArray; + target: z.ZodOptional; + targets: z.ZodOptional>; + groupId: z.ZodOptional>; + condition: z.ZodOptional; + conditions: z.ZodOptional>; + severity: z.ZodEnum<{ + critical: "critical"; + warning: "warning"; + info: "info"; + }>; + enabled: z.ZodBoolean; + cooldown: z.ZodEnum<{ + "1\u043C": "1м"; + "5\u043C": "5м"; + "15\u043C": "15м"; + "1\u0447": "1ч"; + "4\u0447": "4ч"; + "24\u0447": "24ч"; + }>; + confirmStabilitySec: z.ZodOptional>; + recoveryMode: z.ZodOptional>; + recoveryStabilitySec: z.ZodOptional>; + chatId: z.ZodString; + }, z.core.$strip>>; + groups: z.ZodOptional; + enabled: z.ZodBoolean; + cooldownOverride: z.ZodUnion, z.ZodNull]>; + }, z.core.$strip>>>; +}, z.core.$strip>; +export declare const putTelegramSchema: z.ZodObject<{ + token: z.ZodOptional>; + chatId: z.ZodOptional; + messageThreadId: z.ZodOptional>; +}, z.core.$strip>; +export declare const rulePreviewTelegramSchema: z.ZodObject<{ + name: z.ZodString; + targets: z.ZodArray; + conditionLine: z.ZodString; + severity: z.ZodEnum<{ + critical: "critical"; + warning: "warning"; + info: "info"; + }>; + cooldown: z.ZodEnum<{ + "1\u043C": "1м"; + "5\u043C": "5м"; + "15\u043C": "15м"; + "1\u0447": "1ч"; + "4\u0447": "4ч"; + "24\u0447": "24ч"; + }>; +}, z.core.$strip>; +export declare const testTelegramSchema: z.ZodObject<{ + token: z.ZodOptional; + chatId: z.ZodOptional; + messageThreadId: z.ZodOptional>; + rulePreview: z.ZodOptional; + conditionLine: z.ZodString; + severity: z.ZodEnum<{ + critical: "critical"; + warning: "warning"; + info: "info"; + }>; + cooldown: z.ZodEnum<{ + "1\u043C": "1м"; + "5\u043C": "5м"; + "15\u043C": "15м"; + "1\u0447": "1ч"; + "4\u0447": "4ч"; + "24\u0447": "24ч"; + }>; + }, z.core.$strip>>; +}, z.core.$strip>; +export type AlertGroup = z.infer; +export type AlertRule = z.infer; +export type PutRulesBody = z.infer; +export type PutTelegramBody = z.infer; +export type RulePreviewTelegram = z.infer; +export type TestTelegramBody = z.infer; +//# sourceMappingURL=alerts.d.ts.map \ No newline at end of file diff --git a/packages/contracts/src/alerts.d.ts.map b/packages/contracts/src/alerts.d.ts.map new file mode 100644 index 0000000..64baffb --- /dev/null +++ b/packages/contracts/src/alerts.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"alerts.d.ts","sourceRoot":"","sources":["alerts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,eAAe;;;;;;;;;EAS1B,CAAA;AACF,eAAO,MAAM,mBAAmB;;;;EAA0C,CAAA;AAC1E,eAAO,MAAM,mBAAmB;;;;;;;EAAiD,CAAA;AACjF,eAAO,MAAM,kBAAkB;;;;EAA6C,CAAA;AAC5E,eAAO,MAAM,iBAAiB;;;EAAyB,CAAA;AAEvD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;iBAM3B,CAAA;AAEF,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyBxB,CAAA;AAEJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAGzB,CAAA;AAEF,eAAO,MAAM,iBAAiB;;;;iBAI5B,CAAA;AAEF,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;iBAMpC,CAAA;AAEF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;iBAK7B,CAAA;AAEF,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAA;AACzD,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAA;AACvD,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAA;AACzD,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAC/D,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAA;AAC3E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA"} \ No newline at end of file diff --git a/packages/contracts/src/alerts.js b/packages/contracts/src/alerts.js new file mode 100644 index 0000000..277bc2e --- /dev/null +++ b/packages/contracts/src/alerts.js @@ -0,0 +1,71 @@ +import { z } from "zod"; +export const alertTypeSchema = z.enum([ + "gre-tunnel", + "bgp-peer", + "bgp-prefix", + "gre-client", + "server", + "rtt", + "loss", + "traffic", +]); +export const alertSeveritySchema = z.enum(["critical", "warning", "info"]); +export const alertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"]); +export const recoveryModeSchema = z.enum(["always", "never", "conditional"]); +export const combineModeSchema = z.enum(["any", "all"]); +export const alertGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + combineMode: combineModeSchema, + enabled: z.boolean(), + cooldownOverride: z.union([alertCooldownSchema, z.null()]), +}); +export const alertRuleSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + type: alertTypeSchema, + target: z.string().optional(), + targets: z.array(z.string().min(1)).optional(), + groupId: z.string().nullable().optional(), + condition: z.string().optional(), + conditions: z.array(z.string().min(1)).optional(), + severity: alertSeveritySchema, + enabled: z.boolean(), + cooldown: alertCooldownSchema, + confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(), + recoveryMode: recoveryModeSchema.optional(), + recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(), + chatId: z.string(), +}) + .refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), { + message: "Нужен хотя бы один объект: targets или target", + path: ["targets"], +}) + .refine((r) => (r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()), { + message: "Нужно хотя бы одно условие: conditions или condition", + path: ["conditions"], +}); +export const putRulesSchema = z.object({ + rules: z.array(alertRuleSchema), + groups: z.array(alertGroupSchema).optional(), +}); +export const putTelegramSchema = z.object({ + token: z.union([z.string(), z.null()]).optional(), + chatId: z.string().optional(), + messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(), +}); +export const rulePreviewTelegramSchema = z.object({ + name: z.string().min(1), + targets: z.array(z.string().min(1)).min(1), + conditionLine: z.string().min(1), + severity: alertSeveritySchema, + cooldown: alertCooldownSchema, +}); +export const testTelegramSchema = z.object({ + token: z.string().optional(), + chatId: z.string().optional(), + messageThreadId: z.coerce.number().int().positive().optional(), + rulePreview: rulePreviewTelegramSchema.optional(), +}); +//# sourceMappingURL=alerts.js.map \ No newline at end of file diff --git a/packages/contracts/src/alerts.js.map b/packages/contracts/src/alerts.js.map new file mode 100644 index 0000000..788f753 --- /dev/null +++ b/packages/contracts/src/alerts.js.map @@ -0,0 +1 @@ +{"version":3,"file":"alerts.js","sourceRoot":"","sources":["alerts.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/contracts/src/alerts.ts b/packages/contracts/src/alerts.ts new file mode 100644 index 0000000..faffcbc --- /dev/null +++ b/packages/contracts/src/alerts.ts @@ -0,0 +1,84 @@ +import { z } from "zod" + +export const alertTypeSchema = z.enum([ + "gre-tunnel", + "bgp-peer", + "bgp-prefix", + "gre-client", + "server", + "rtt", + "loss", + "traffic", +]) +export const alertSeveritySchema = z.enum(["critical", "warning", "info"]) +export const alertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"]) +export const recoveryModeSchema = z.enum(["always", "never", "conditional"]) +export const combineModeSchema = z.enum(["any", "all"]) + +export const alertGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + combineMode: combineModeSchema, + enabled: z.boolean(), + cooldownOverride: z.union([alertCooldownSchema, z.null()]), +}) + +export const alertRuleSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + type: alertTypeSchema, + target: z.string().optional(), + targets: z.array(z.string().min(1)).optional(), + groupId: z.string().nullable().optional(), + condition: z.string().optional(), + conditions: z.array(z.string().min(1)).optional(), + severity: alertSeveritySchema, + enabled: z.boolean(), + cooldown: alertCooldownSchema, + confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(), + recoveryMode: recoveryModeSchema.optional(), + recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(), + chatId: z.string(), + }) + .refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), { + message: "Нужен хотя бы один объект: targets или target", + path: ["targets"], + }) + .refine((r) => (r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()), { + message: "Нужно хотя бы одно условие: conditions или condition", + path: ["conditions"], + }) + +export const putRulesSchema = z.object({ + rules: z.array(alertRuleSchema), + groups: z.array(alertGroupSchema).optional(), +}) + +export const putTelegramSchema = z.object({ + token: z.union([z.string(), z.null()]).optional(), + chatId: z.string().optional(), + messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(), +}) + +export const rulePreviewTelegramSchema = z.object({ + name: z.string().min(1), + targets: z.array(z.string().min(1)).min(1), + conditionLine: z.string().min(1), + severity: alertSeveritySchema, + cooldown: alertCooldownSchema, +}) + +export const testTelegramSchema = z.object({ + token: z.string().optional(), + chatId: z.string().optional(), + messageThreadId: z.coerce.number().int().positive().optional(), + rulePreview: rulePreviewTelegramSchema.optional(), +}) + +export type AlertGroup = z.infer +export type AlertRule = z.infer +export type PutRulesBody = z.infer +export type PutTelegramBody = z.infer +export type RulePreviewTelegram = z.infer +export type TestTelegramBody = z.infer diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..c5bfb36 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,2 @@ +export * from "./servers.js" +export * from "./alerts.js" diff --git a/packages/contracts/src/servers.d.ts b/packages/contracts/src/servers.d.ts new file mode 100644 index 0000000..1f09bc2 --- /dev/null +++ b/packages/contracts/src/servers.d.ts @@ -0,0 +1,228 @@ +import { z } from "zod"; +export declare const serverTypeSchema: z.ZodEnum<{ + "jump-host": "jump-host"; + "exit-node": "exit-node"; + "home-router": "home-router"; +}>; +export declare const serverStatusSchema: z.ZodEnum<{ + online: "online"; + offline: "offline"; +}>; +export declare const wanUplinkSchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + isp: z.ZodString; + iface: z.ZodString; + ip: z.ZodString; + maxDl: z.ZodNumber; + maxUl: z.ZodNumber; +}, z.core.$strip>; +export declare const serverCreateSchema: z.ZodObject<{ + host: z.ZodString; + port: z.ZodDefault; + username: z.ZodDefault; + password: z.ZodDefault; + useSsl: z.ZodDefault; + verifySsl: z.ZodDefault; + name: z.ZodDefault; + type: z.ZodDefault>; + site: z.ZodDefault; + country: z.ZodDefault; + asn: z.ZodDefault; + comment: z.ZodDefault; + enabled: z.ZodDefault; + lanSubnet: z.ZodDefault; + wanUplinks: z.ZodDefault>>; +}, z.core.$strip>; +export declare const serverUpdateSchema: z.ZodObject<{ + type: z.ZodOptional>>; + name: z.ZodOptional>; + port: z.ZodOptional>; + username: z.ZodOptional>; + password: z.ZodOptional>; + useSsl: z.ZodOptional>; + verifySsl: z.ZodOptional>; + site: z.ZodOptional>; + country: z.ZodOptional>; + asn: z.ZodOptional>; + comment: z.ZodOptional>; + enabled: z.ZodOptional>; + lanSubnet: z.ZodOptional>; + wanUplinks: z.ZodOptional>>>; + host: z.ZodOptional; +}, z.core.$strip>; +export declare const serverIdParamSchema: z.ZodObject<{ + id: z.ZodCoercedNumber; +}, z.core.$strip>; +export declare const snapshotsQuerySchema: z.ZodObject<{ + limit: z.ZodDefault>; +}, z.core.$strip>; +export declare const testConnectionSchema: z.ZodObject<{ + host: z.ZodString; + port: z.ZodDefault; + useSsl: z.ZodDefault; + verifySsl: z.ZodDefault; + apiPath: z.ZodDefault; + username: z.ZodDefault; + password: z.ZodDefault; +}, z.core.$strip>; +export declare const serverReadSchema: z.ZodObject<{ + id: z.ZodNumber; + name: z.ZodString; + host: z.ZodString; + port: z.ZodNumber; + useSsl: z.ZodBoolean; + verifySsl: z.ZodBoolean; + username: z.ZodString; + password: z.ZodString; + type: z.ZodEnum<{ + "jump-host": "jump-host"; + "exit-node": "exit-node"; + "home-router": "home-router"; + }>; + site: z.ZodString; + country: z.ZodString; + asn: z.ZodString; + comment: z.ZodString; + enabled: z.ZodBoolean; + lanSubnet: z.ZodString; + wanUplinks: z.ZodArray>; + createdAt: z.ZodString; + updatedAt: z.ZodString; + status: z.ZodUnion, z.ZodNull]>; + latency: z.ZodUnion; + os: z.ZodUnion; + model: z.ZodUnion; + uptime: z.ZodUnion; + cpuLoad: z.ZodUnion; + freeMemory: z.ZodUnion; + totalMemory: z.ZodUnion; + identityName: z.ZodUnion; + sessions: z.ZodNumber; + polledAt: z.ZodUnion; +}, z.core.$strip>; +export declare const snapshotReadSchema: z.ZodObject<{ + id: z.ZodNumber; + serverId: z.ZodNumber; + polledAt: z.ZodString; + status: z.ZodEnum<{ + online: "online"; + offline: "offline"; + }>; + latencyMs: z.ZodUnion; + rosVersion: z.ZodUnion; + boardName: z.ZodUnion; + uptime: z.ZodUnion; + cpuLoad: z.ZodUnion; + freeMemory: z.ZodUnion; + totalMemory: z.ZodUnion; + identityName: z.ZodUnion; +}, z.core.$strip>; +export declare const serverListSchema: z.ZodArray; + site: z.ZodString; + country: z.ZodString; + asn: z.ZodString; + comment: z.ZodString; + enabled: z.ZodBoolean; + lanSubnet: z.ZodString; + wanUplinks: z.ZodArray>; + createdAt: z.ZodString; + updatedAt: z.ZodString; + status: z.ZodUnion, z.ZodNull]>; + latency: z.ZodUnion; + os: z.ZodUnion; + model: z.ZodUnion; + uptime: z.ZodUnion; + cpuLoad: z.ZodUnion; + freeMemory: z.ZodUnion; + totalMemory: z.ZodUnion; + identityName: z.ZodUnion; + sessions: z.ZodNumber; + polledAt: z.ZodUnion; +}, z.core.$strip>>; +export declare const snapshotListSchema: z.ZodArray; + latencyMs: z.ZodUnion; + rosVersion: z.ZodUnion; + boardName: z.ZodUnion; + uptime: z.ZodUnion; + cpuLoad: z.ZodUnion; + freeMemory: z.ZodUnion; + totalMemory: z.ZodUnion; + identityName: z.ZodUnion; +}, z.core.$strip>>; +export type ServerType = z.infer; +export type WanUplink = z.infer; +export type ServerCreate = z.infer; +export type ServerUpdate = z.infer; +export type ServerRead = z.infer; +export type SnapshotRead = z.infer; +export type ServerIdParam = z.infer; +export type SnapshotsQuery = z.infer; +export type TestConnectionRequest = z.infer; +//# sourceMappingURL=servers.d.ts.map \ No newline at end of file diff --git a/packages/contracts/src/servers.d.ts.map b/packages/contracts/src/servers.d.ts.map new file mode 100644 index 0000000..b4a2fb1 --- /dev/null +++ b/packages/contracts/src/servers.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"servers.d.ts","sourceRoot":"","sources":["servers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,gBAAgB;;;;EAAoD,CAAA;AACjF,eAAO,MAAM,kBAAkB;;;EAAgC,CAAA;AAE/D,eAAO,MAAM,eAAe;;;;;;;;iBAQ1B,CAAA;AAEF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgB7B,CAAA;AAEF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAE7B,CAAA;AAEF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AAEF,eAAO,MAAM,oBAAoB;;iBAE/B,CAAA;AAEF,eAAO,MAAM,oBAAoB;;;;;;;;iBAQ/B,CAAA;AAEF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8B3B,CAAA;AAEF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;iBAa7B,CAAA;AAEF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAA4B,CAAA;AACzD,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;kBAA8B,CAAA;AAE7D,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAA;AACzD,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAA;AACvD,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC7D,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC7D,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAA;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC7D,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAC/D,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAA;AACjE,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAA"} \ No newline at end of file diff --git a/packages/contracts/src/servers.js b/packages/contracts/src/servers.js new file mode 100644 index 0000000..0d331f0 --- /dev/null +++ b/packages/contracts/src/servers.js @@ -0,0 +1,95 @@ +import { z } from "zod"; +export const serverTypeSchema = z.enum(["jump-host", "exit-node", "home-router"]); +export const serverStatusSchema = z.enum(["online", "offline"]); +export const wanUplinkSchema = z.object({ + id: z.string(), + name: z.string(), + isp: z.string(), + iface: z.string(), + ip: z.string(), + maxDl: z.number(), + maxUl: z.number(), +}); +export const serverCreateSchema = z.object({ + host: z.string().min(1, "Host is required"), + port: z.number().int().positive().default(443), + username: z.string().default("admin"), + password: z.string().default(""), + useSsl: z.boolean().default(true), + verifySsl: z.boolean().default(false), + name: z.string().default(""), + type: serverTypeSchema.default("home-router"), + site: z.string().default(""), + country: z.string().default(""), + asn: z.string().default(""), + comment: z.string().default(""), + enabled: z.boolean().default(true), + lanSubnet: z.string().default(""), + wanUplinks: z.array(wanUplinkSchema).default([]), +}); +export const serverUpdateSchema = serverCreateSchema.partial().omit({ host: true }).extend({ + host: z.string().min(1).optional(), +}); +export const serverIdParamSchema = z.object({ + id: z.coerce.number().int().positive(), +}); +export const snapshotsQuerySchema = z.object({ + limit: z.coerce.number().int().positive().max(500).default(50), +}); +export const testConnectionSchema = z.object({ + host: z.string().min(1), + port: z.number().int().positive().default(443), + useSsl: z.boolean().default(true), + verifySsl: z.boolean().default(false), + apiPath: z.string().default("/rest"), + username: z.string().default("admin"), + password: z.string().default(""), +}); +export const serverReadSchema = z.object({ + id: z.number().int(), + name: z.string(), + host: z.string(), + port: z.number().int(), + useSsl: z.boolean(), + verifySsl: z.boolean(), + username: z.string(), + password: z.string(), + type: serverTypeSchema, + site: z.string(), + country: z.string(), + asn: z.string(), + comment: z.string(), + enabled: z.boolean(), + lanSubnet: z.string(), + wanUplinks: z.array(wanUplinkSchema), + createdAt: z.string(), + updatedAt: z.string(), + status: z.union([serverStatusSchema, z.null()]), + latency: z.union([z.number(), z.null()]), + os: z.union([z.string(), z.null()]), + model: z.union([z.string(), z.null()]), + uptime: z.union([z.string(), z.null()]), + cpuLoad: z.union([z.number(), z.null()]), + freeMemory: z.union([z.number(), z.null()]), + totalMemory: z.union([z.number(), z.null()]), + identityName: z.union([z.string(), z.null()]), + sessions: z.number().int(), + polledAt: z.union([z.string(), z.null()]), +}); +export const snapshotReadSchema = z.object({ + id: z.number().int(), + serverId: z.number().int(), + polledAt: z.string(), + status: serverStatusSchema, + latencyMs: z.union([z.number(), z.null()]), + rosVersion: z.union([z.string(), z.null()]), + boardName: z.union([z.string(), z.null()]), + uptime: z.union([z.string(), z.null()]), + cpuLoad: z.union([z.number().int(), z.null()]), + freeMemory: z.union([z.number().int(), z.null()]), + totalMemory: z.union([z.number().int(), z.null()]), + identityName: z.union([z.string(), z.null()]), +}); +export const serverListSchema = z.array(serverReadSchema); +export const snapshotListSchema = z.array(snapshotReadSchema); +//# sourceMappingURL=servers.js.map \ No newline at end of file diff --git a/packages/contracts/src/servers.js.map b/packages/contracts/src/servers.js.map new file mode 100644 index 0000000..7c47f46 --- /dev/null +++ b/packages/contracts/src/servers.js.map @@ -0,0 +1 @@ +{"version":3,"file":"servers.js","sourceRoot":"","sources":["servers.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/contracts/src/servers.ts b/packages/contracts/src/servers.ts new file mode 100644 index 0000000..d1f2a7f --- /dev/null +++ b/packages/contracts/src/servers.ts @@ -0,0 +1,114 @@ +import { z } from "zod" + +export const serverTypeSchema = z.enum(["jump-host", "exit-node", "home-router"]) +export const serverStatusSchema = z.enum(["online", "offline"]) + +export const wanUplinkSchema = z.object({ + id: z.string(), + name: z.string(), + isp: z.string(), + iface: z.string(), + ip: z.string(), + maxDl: z.number(), + maxUl: z.number(), +}) + +export const serverCreateSchema = z.object({ + host: z.string().min(1, "Host is required"), + port: z.number().int().positive().default(443), + username: z.string().default("admin"), + password: z.string().default(""), + useSsl: z.boolean().default(true), + verifySsl: z.boolean().default(false), + name: z.string().default(""), + type: serverTypeSchema.default("home-router"), + site: z.string().default(""), + country: z.string().default(""), + asn: z.string().default(""), + comment: z.string().default(""), + enabled: z.boolean().default(true), + lanSubnet: z.string().default(""), + wanUplinks: z.array(wanUplinkSchema).default([]), +}) + +export const serverUpdateSchema = serverCreateSchema.partial().omit({ host: true }).extend({ + host: z.string().min(1).optional(), +}) + +export const serverIdParamSchema = z.object({ + id: z.coerce.number().int().positive(), +}) + +export const snapshotsQuerySchema = z.object({ + limit: z.coerce.number().int().positive().max(500).default(50), +}) + +export const testConnectionSchema = z.object({ + host: z.string().min(1), + port: z.number().int().positive().default(443), + useSsl: z.boolean().default(true), + verifySsl: z.boolean().default(false), + apiPath: z.string().default("/rest"), + username: z.string().default("admin"), + password: z.string().default(""), +}) + +export const serverReadSchema = z.object({ + id: z.number().int(), + name: z.string(), + host: z.string(), + port: z.number().int(), + useSsl: z.boolean(), + verifySsl: z.boolean(), + username: z.string(), + password: z.string(), + type: serverTypeSchema, + site: z.string(), + country: z.string(), + asn: z.string(), + comment: z.string(), + enabled: z.boolean(), + lanSubnet: z.string(), + wanUplinks: z.array(wanUplinkSchema), + createdAt: z.string(), + updatedAt: z.string(), + status: z.union([serverStatusSchema, z.null()]), + latency: z.union([z.number(), z.null()]), + os: z.union([z.string(), z.null()]), + model: z.union([z.string(), z.null()]), + uptime: z.union([z.string(), z.null()]), + cpuLoad: z.union([z.number(), z.null()]), + freeMemory: z.union([z.number(), z.null()]), + totalMemory: z.union([z.number(), z.null()]), + identityName: z.union([z.string(), z.null()]), + sessions: z.number().int(), + polledAt: z.union([z.string(), z.null()]), +}) + +export const snapshotReadSchema = z.object({ + id: z.number().int(), + serverId: z.number().int(), + polledAt: z.string(), + status: serverStatusSchema, + latencyMs: z.union([z.number(), z.null()]), + rosVersion: z.union([z.string(), z.null()]), + boardName: z.union([z.string(), z.null()]), + uptime: z.union([z.string(), z.null()]), + cpuLoad: z.union([z.number().int(), z.null()]), + freeMemory: z.union([z.number().int(), z.null()]), + totalMemory: z.union([z.number().int(), z.null()]), + identityName: z.union([z.string(), z.null()]), +}) + +export const serverListSchema = z.array(serverReadSchema) +export const snapshotListSchema = z.array(snapshotReadSchema) + +export type ServerType = z.infer +export type WanUplink = z.infer +export type ServerCreate = z.infer +export type ServerUpdate = z.infer +export type ServerRead = z.infer +export type SnapshotRead = z.infer +export type ServerIdParam = z.infer +export type SnapshotsQuery = z.infer +export type TestConnectionRequest = z.infer diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..f7d567b --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/shared/api/http-client.ts b/shared/api/http-client.ts new file mode 100644 index 0000000..0ee4c4c --- /dev/null +++ b/shared/api/http-client.ts @@ -0,0 +1,45 @@ +export class ApiClientError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly payload?: unknown, + ) { + super(message) + this.name = "ApiClientError" + } +} + +function trimBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/$/, "") +} + +export async function requestJson( + baseUrl: string, + path: string, + init?: RequestInit, +): Promise { + const hasBody = init?.body != null + const res = await fetch(trimBaseUrl(baseUrl) + path, { + ...init, + headers: { + ...(hasBody ? { "Content-Type": "application/json" } : {}), + ...(init?.headers ?? {}), + }, + }) + + if (res.status === 204) return undefined as T + + const payload = await res.json().catch(() => undefined) + if (!res.ok) { + const msg = + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof (payload as { error?: unknown }).error === "string" + ? (payload as { error: string }).error + : res.statusText + throw new ApiClientError(msg, res.status, payload) + } + + return payload as T +} diff --git a/shared/api/servers.ts b/shared/api/servers.ts new file mode 100644 index 0000000..5c2a607 --- /dev/null +++ b/shared/api/servers.ts @@ -0,0 +1,65 @@ +import { + serverListSchema, + serverReadSchema, + testConnectionSchema, + type ServerCreate, + type ServerRead, + type ServerUpdate, + type TestConnectionRequest, +} from "@/packages/contracts/src/servers" +import { requestJson } from "@/shared/api/http-client" + +type TestConnectionResponse = { + success: boolean + latencyMs?: number + identity?: string + version?: string + boardName?: string + uptime?: string + message: string +} + +export async function listServers(baseUrl: string): Promise { + const payload = await requestJson(baseUrl, "/api/servers") + return serverListSchema.parse(payload) +} + +export async function getServer(baseUrl: string, id: string): Promise { + const payload = await requestJson(baseUrl, `/api/servers/${id}`) + return serverReadSchema.parse(payload) +} + +export async function createServer(baseUrl: string, data: ServerCreate): Promise { + const payload = await requestJson(baseUrl, "/api/servers", { + method: "POST", + body: JSON.stringify(data), + }) + return serverReadSchema.parse(payload) +} + +export async function updateServer(baseUrl: string, id: string, data: ServerUpdate): Promise { + const payload = await requestJson(baseUrl, `/api/servers/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }) + return serverReadSchema.parse(payload) +} + +export async function deleteServer(baseUrl: string, id: string): Promise { + await requestJson(baseUrl, `/api/servers/${id}`, { method: "DELETE" }) +} + +export async function pollServer(baseUrl: string, id: string): Promise { + await requestJson(baseUrl, `/api/servers/${id}/poll`, { method: "POST" }) +} + +export async function testServerConnection( + baseUrl: string, + payload: TestConnectionRequest, +): Promise { + testConnectionSchema.parse(payload) + return requestJson(baseUrl, "/api/servers/test-connection", { + method: "POST", + body: JSON.stringify(payload), + }) +}