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), + }) +}