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.
This commit is contained in:
Denozordec
2026-05-07 13:35:41 +07:00
parent 5f31bb47fb
commit 6d8379501c
42 changed files with 1336 additions and 631 deletions
+2 -11
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { Flag, countryName } from "@/components/flag" import { Flag, countryName } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -165,17 +166,7 @@ interface BgpSessionListRow {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -17
View File
@@ -24,27 +24,12 @@ import { Flag } from "@/components/flag"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react" import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency" import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -17
View File
@@ -23,6 +23,7 @@ import {
type SchedulerRunRowDto, type SchedulerRunRowDto,
type UptimeSettingsDto, type UptimeSettingsDto,
} from "@/lib/scheduler-settings" } from "@/lib/scheduler-settings"
import { requestJson } from "@/shared/api/http-client"
import { import {
parseSchedulerRunSnapshot, parseSchedulerRunSnapshot,
type AlertEngineRuleDiagSnapshot, type AlertEngineRuleDiagSnapshot,
@@ -49,23 +50,7 @@ import {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -10
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { import {
servers, greTunnels, serverFilterRulesets, filters as mockFiltersCatalog, servers, greTunnels, serverFilterRulesets, filters as mockFiltersCatalog,
type FilterRule, type ServerFilterRuleset, type Server, type GreTunnel, type FilterRule, type ServerFilterRuleset, type Server, type GreTunnel,
@@ -1491,16 +1492,7 @@ function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -10
View File
@@ -5,6 +5,7 @@ import { PageHeader } from "@/components/page-header"
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data" 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 type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -210,16 +211,7 @@ interface GreTunnelsApiResponse {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -10
View File
@@ -11,6 +11,7 @@ import {
type ServerType, type ServerType,
} from "@/lib/data" } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { import {
buildGreMapEdges, buildGreMapEdges,
buildServerResourceMap, buildServerResourceMap,
@@ -175,16 +176,7 @@ function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -10
View File
@@ -14,6 +14,7 @@ import {
LoaderCircleIcon, AlertCircleIcon, LoaderCircleIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -66,16 +67,7 @@ const TOOL_META: Record<DiagTool, {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -17
View File
@@ -12,6 +12,7 @@ import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data" import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react" import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client"
interface BackendServer { interface BackendServer {
id: number id: number
@@ -91,23 +92,7 @@ interface RouteGroup {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -17
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { servers } from "@/lib/data" import { servers } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { import {
@@ -40,23 +41,7 @@ import {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+35 -96
View File
@@ -5,71 +5,21 @@ import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge" import { StatusBadge } from "@/components/status-badge"
import { servers as initialServers } from "@/lib/data" import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } 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 ────────────────────────────────────────────────────── // ─── Backend integration ──────────────────────────────────────────────────────
import { useDataSource } from "@/lib/data-source" 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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>
}
}
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
@@ -373,7 +323,6 @@ type SheetMode = "add" | "edit"
export default function ServersPage() { export default function ServersPage() {
const { mode, backendUrl, backendStatus } = useDataSource() const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true const isLive = mode === "live" && backendStatus === true
const apiFetch = makeApiFetch(backendUrl)
const [serverList, setServerList] = useState<Server[]>(initialServers) const [serverList, setServerList] = useState<Server[]>(initialServers)
const [_backendOk, setBackendOk] = useState(false) const [_backendOk, setBackendOk] = useState(false)
@@ -402,10 +351,10 @@ export default function ServersPage() {
}) })
return return
} }
apiFetch<BackendServer[]>("/api/servers") listServers(backendUrl)
.then(data => { .then(data => {
setBackendOk(true) setBackendOk(true)
setServerList(data.map(toFrontend)) setServerList(data.map(toFrontendServer))
}) })
.catch(() => { .catch(() => {
setBackendOk(false) setBackendOk(false)
@@ -437,7 +386,7 @@ export default function ServersPage() {
// Fetch full server details (including credentials) from backend // Fetch full server details (including credentials) from backend
if (isLive) { if (isLive) {
try { try {
const full = await apiFetch<BackendServer>(`/api/servers/${s.id}`) const full = await getServer(backendUrl, s.id)
setForm(prev => ({ setForm(prev => ({
...prev, ...prev,
username: full.username ?? "", username: full.username ?? "",
@@ -458,7 +407,7 @@ export default function ServersPage() {
} }
async function handleSave() { async function handleSave() {
const payload = { const payload: ServerCreate = {
host: form.host, host: form.host,
port: Number(form.port) || (form.proto === "https" ? 443 : 80), port: Number(form.port) || (form.proto === "https" ? 443 : 80),
username: form.username, username: form.username,
@@ -479,17 +428,12 @@ export default function ServersPage() {
if (isLive) { if (isLive) {
try { try {
if (sheetMode === "edit" && editingId) { if (sheetMode === "edit" && editingId) {
const updated = await apiFetch<BackendServer>( const updatedPayload: ServerUpdate = payload
`/api/servers/${editingId}`, const updated = await updateServer(backendUrl, editingId, updatedPayload)
{ method: "PUT", body: JSON.stringify(payload) }, setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
)
setServerList(list => list.map(s => s.id === editingId ? toFrontend(updated) : s))
} else { } else {
const created = await apiFetch<BackendServer>( const created = await createServer(backendUrl, payload)
"/api/servers", setServerList(list => [...list, toFrontendServer(created)])
{ method: "POST", body: JSON.stringify(payload) },
)
setServerList(list => [...list, toFrontend(created)])
} }
} catch (e) { } catch (e) {
console.error("Ошибка сохранения:", e) console.error("Ошибка сохранения:", e)
@@ -540,7 +484,7 @@ export default function ServersPage() {
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (isLive) { if (isLive) {
try { try {
await apiFetch(`/api/servers/${id}`, { method: "DELETE" }) await deleteServer(backendUrl, id)
} catch (e) { } catch (e) {
console.error("Ошибка удаления:", e) console.error("Ошибка удаления:", e)
} }
@@ -552,10 +496,10 @@ export default function ServersPage() {
if (!isLive) return if (!isLive) return
setPollingIds(p => new Set(p).add(id)) setPollingIds(p => new Set(p).add(id))
try { try {
await apiFetch(`/api/servers/${id}/poll`, { method: "POST" }) await pollServer(backendUrl, id)
// Reload full list to get updated status/version // Reload full list to get updated status/version
const data = await apiFetch<BackendServer[]>("/api/servers") const data = await listServers(backendUrl)
setServerList(data.map(toFrontend)) setServerList(data.map(toFrontendServer))
} catch (e) { } catch (e) {
console.error("Poll error:", e) console.error("Poll error:", e)
} finally { } finally {
@@ -568,9 +512,9 @@ export default function ServersPage() {
setPollAllBusy(true) setPollAllBusy(true)
try { try {
const ids = serverList.map(s => s.id) const ids = serverList.map(s => s.id)
await Promise.all(ids.map(id => apiFetch(`/api/servers/${id}/poll`, { method: "POST" }).catch(() => {}))) await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {})))
const data = await apiFetch<BackendServer[]>("/api/servers") const data = await listServers(backendUrl)
setServerList(data.map(toFrontend)) setServerList(data.map(toFrontendServer))
} finally { } finally {
setPollAllBusy(false) setPollAllBusy(false)
} }
@@ -582,20 +526,15 @@ export default function ServersPage() {
} }
setTestState("testing"); setTestMsg("") setTestState("testing"); setTestMsg("")
try { try {
const res = await fetch(backendUrl.replace(/\/$/, "") + "/api/servers/test-connection", { const data = await testServerConnection(backendUrl, {
method: "POST", host: form.host,
headers: { "Content-Type": "application/json" }, port: Number(form.port) || (form.proto === "https" ? 443 : 80),
body: JSON.stringify({ useSsl: form.proto === "https",
host: form.host, verifySsl: form.verifySsl,
port: Number(form.port) || (form.proto === "https" ? 443 : 80), apiPath: form.apiPath || "/rest",
useSsl: form.proto === "https", username: form.username,
verifySsl: form.verifySsl, password: form.password,
apiPath: form.apiPath || "/rest",
username: form.username,
password: form.password,
}),
}) })
const data = await res.json() as { success: boolean; message: string }
if (data.success) { if (data.success) {
setTestState("ok"); setTestMsg(data.message) setTestState("ok"); setTestMsg(data.message)
} else { } else {
+2 -17
View File
@@ -24,6 +24,7 @@ import {
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon, ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -152,23 +153,7 @@ type NavSection = typeof SECTIONS_NAV[number]
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -17
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -126,23 +127,7 @@ interface LiveTrafficInterface {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
+2 -17
View File
@@ -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 { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
import type { PingProbe } from "@/lib/data" import type { PingProbe } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { import {
RefreshCwIcon, PlusIcon, SearchIcon, XIcon, RefreshCwIcon, PlusIcon, SearchIcon, XIcon,
ChevronDownIcon, ChevronRightIcon, ChevronDownIcon, ChevronRightIcon,
@@ -63,23 +64,7 @@ function mockProbesWithSavedStars(base: PingProbe[]): PingProbe[] {
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} return requestJson<T>(backendUrl, path, init)
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<T>
} }
} }
@@ -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<string, unknown>
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,
}
}
@@ -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<typeof servers.$inferInsert, "id">,
): ServerRow {
const [inserted] = db.insert(servers).values(values).returning().all()
return inserted
}
export function updateServerRowById(
id: number,
values: Partial<ServerRow>,
): 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]
}
@@ -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<string, unknown> = { 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)
}
+8 -88
View File
@@ -1,5 +1,4 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { import {
getAlertsMeta, getAlertsMeta,
getTelegramBotToken, getTelegramBotToken,
@@ -13,90 +12,11 @@ import {
sendTelegramAlertMessage, sendTelegramAlertMessage,
updateTelegramSettings, updateTelegramSettings,
} from "../services/alerts-service.js" } from "../services/alerts-service.js"
import {
const AlertTypeSchema = z.enum([ putRulesSchema,
"gre-tunnel", putTelegramSchema,
"bgp-peer", testTelegramSchema,
"bgp-prefix", } from "../../../packages/contracts/dist/alerts.js"
"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(),
})
const alertsRoutes: FastifyPluginAsyncZod = async (app) => { const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/alerts", async (_req, reply) => { app.get("/alerts", async (_req, reply) => {
@@ -112,7 +32,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
app.put("/alerts/rules", async (req, reply) => { app.put("/alerts/rules", async (req, reply) => {
const parsed = PutRulesSchema.safeParse(req.body) const parsed = putRulesSchema.safeParse(req.body)
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) 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) => { app.put("/alerts/telegram", async (req, reply) => {
const parsed = PutTelegramSchema.safeParse(req.body) const parsed = putTelegramSchema.safeParse(req.body)
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) 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) => { app.post("/alerts/telegram/test", async (req, reply) => {
const parsed = TestTelegramSchema.safeParse(req.body ?? {}) const parsed = testTelegramSchema.safeParse(req.body ?? {})
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
+5 -3
View File
@@ -4,14 +4,15 @@ import { db } from "../db/index.js"
import { servers } from "../db/schema.js" import { servers } from "../db/schema.js"
import { parseBgpSessions } from "../services/bgp-parse-sessions.js" import { parseBgpSessions } from "../services/bgp-parse-sessions.js"
import { MikrotikClient } from "../services/mikrotik.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" import type { BgpSessionRead } from "../types/server.js"
const bgpRoutes: FastifyPluginAsyncZod = async (app) => { const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug) // 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) => { 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" }) if (!server) return reply.status(404).send({ error: "Server not found" })
try { try {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
@@ -44,9 +45,10 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/bgp/sessions — single server // GET /api/servers/:id/bgp/sessions — single server
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = db const server = db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, req.params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
+3 -2
View File
@@ -4,7 +4,7 @@ import { z } from "zod"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { servers } from "../db/schema.js" import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type { import type {
RosIpAddress, RosInterface, RosResource, RosIdentity, RosIpRoute, RosIpAddress, RosInterface, RosResource, RosIdentity, RosIpRoute,
RosFirewallFilter, RosLogEntry, RosBgpSession, RosPingResult, RosFirewallFilter, RosLogEntry, RosBgpSession, RosPingResult,
@@ -313,9 +313,10 @@ const execRoutes: FastifyPluginAsyncZod = async (app) => {
"/servers/:id/exec", "/servers/:id/exec",
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } }, { schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams
const server = db const server = db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, req.params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
+7 -4
View File
@@ -3,7 +3,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js" import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type { import type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession, RosBfdSession,
@@ -630,9 +630,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/ospf — single server OSPF + BFD data // GET /api/servers/:id/ospf — single server OSPF + BFD data
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = db const server = db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, req.params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) 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", "/servers/:id/ospf/optimize/preview",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams
const server = db const server = db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, req.params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -688,9 +690,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
"/servers/:id/ospf/optimize", "/servers/:id/ospf/optimize",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => { async (req, reply) => {
const params = req.params as ServerIdParams
const server = db const server = db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, req.params.id)) .where(eq(servers.id, params.id))
.limit(1).all()[0] .limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
+36 -161
View File
@@ -1,95 +1,28 @@
import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" 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 { import {
ServerCreateSchema, ServerCreateSchema,
ServerUpdateSchema, ServerUpdateSchema,
ServerIdParamSchema, ServerIdParamSchema,
SnapshotsQuerySchema, SnapshotsQuerySchema,
TestConnectionSchema, TestConnectionSchema,
type ServerCreateRequest,
type ServerIdParams,
type ServerUpdateRequest,
type SnapshotsQuery,
type TestConnectionRequest,
} from "../types/server.js" } 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 { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js" import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
import {
// ── helpers ──────────────────────────────────────────────────────────────────── createServer,
deleteServer,
type ServerRow = typeof servers.$inferSelect getServerReadById,
type SnapshotRow = typeof serverSnapshots.$inferSelect listServerSnapshots,
listServersRead,
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] { updateServer,
if (raw == null || raw === "") return [] } from "../modules/servers/service/servers-service.js"
try { import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
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<string, unknown>
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]
}
// ── plugin ───────────────────────────────────────────────────────────────────── // ── plugin ─────────────────────────────────────────────────────────────────────
@@ -97,7 +30,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers/test-connection — check credentials before saving // POST /api/servers/test-connection — check credentials before saving
app.post("/test-connection", { schema: { body: TestConnectionSchema } }, async (req, reply) => { 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 }) const client = new MikrotikClient({ host, port, useSsl, verifySsl, apiPath, username, password })
try { try {
@@ -144,17 +77,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers // GET /api/servers
app.get("/", async (_req, reply) => { app.get("/", async (_req, reply) => {
const all = db.select().from(servers).all() return reply.send(listServersRead())
const result: ServerRead[] = all.map(s => toServerRead(s, getLatestSnapshot(s.id)))
return reply.send(result)
}) })
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN) // GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db const params = req.params as ServerIdParams
.select().from(servers) const server = getServerRowById(params.id)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const ipv4 = await resolveRosSrcIpv4(server.host) const ipv4 = await resolveRosSrcIpv4(server.host)
@@ -163,30 +92,15 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers // POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => { app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
const now = new Date().toISOString() return reply.status(201).send(createServer(req.body as ServerCreateRequest))
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))
}) })
// GET /api/servers/:id // GET /api/servers/:id
app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db const params = req.params as ServerIdParams
.select().from(servers) const server = getServerReadById(params.id)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) 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 // PUT /api/servers/:id
@@ -194,58 +108,30 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
"/:id", "/:id",
{ schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } }, { schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } },
async (req, reply) => { async (req, reply) => {
const existing = db const params = req.params as ServerIdParams
.select().from(servers) const existing = getServerReadById(params.id)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
return reply.send(updateServer(params.id, req.body as ServerUpdateRequest))
const body = req.body
const { wanUplinks, ...rest } = body
const setPayload: Record<string, unknown> = { 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<ServerRow>)
.where(eq(servers.id, req.params.id))
.returning()
.all()
return reply.send(toServerRead(updated, getLatestSnapshot(updated.id)))
}, },
) )
// DELETE /api/servers/:id // DELETE /api/servers/:id
app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const existing = db const params = req.params as ServerIdParams
.select().from(servers) const existing = getServerReadById(params.id)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
deleteServer(params.id)
db.delete(servers).where(eq(servers.id, req.params.id)).run()
return reply.status(204).send() return reply.status(204).send()
}) })
// POST /api/servers/:id/poll // POST /api/servers/:id/poll
app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const existing = db const params = req.params as ServerIdParams
.select().from(servers) const existing = getServerReadById(params.id)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
try { try {
const snap = await pollServer(req.params.id) const snap = await pollServer(params.id)
return reply.send(snap) return reply.send(snap)
} catch (err) { } catch (err) {
return reply.status(500).send({ error: (err as Error).message }) return reply.status(500).send({ error: (err as Error).message })
@@ -257,22 +143,11 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
"/:id/snapshots", "/:id/snapshots",
{ schema: { params: ServerIdParamSchema, querystring: SnapshotsQuerySchema } }, { schema: { params: ServerIdParamSchema, querystring: SnapshotsQuerySchema } },
async (req, reply) => { async (req, reply) => {
const existing = db const params = req.params as ServerIdParams
.select().from(servers) const query = req.query as SnapshotsQuery
.where(eq(servers.id, req.params.id)) const existing = getServerReadById(params.id)
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" }) if (!existing) return reply.status(404).send({ error: "Server not found" })
return reply.send(listServerSnapshots(params.id, query.limit))
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))
}, },
) )
} }
+29 -105
View File
@@ -1,4 +1,16 @@
import { z } from "zod" 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 ──────────────────────────────────────────────── // ── RouterOS raw response types ────────────────────────────────────────────────
@@ -49,116 +61,28 @@ export interface RosIpAddress {
// ── Zod schemas for API validation ──────────────────────────────────────────── // ── Zod schemas for API validation ────────────────────────────────────────────
export const WanUplinkSchema = z.object({ export const ServerCreateSchema = serverCreateSchema
id: z.string(), export const ServerUpdateSchema = serverUpdateSchema
name: z.string(), export const TestConnectionSchema = testConnectionSchema
isp: z.string(), export const ServerIdParamSchema = serverIdParamSchema
iface: z.string(), export const SnapshotsQuerySchema = snapshotsQuerySchema
ip: z.string(),
maxDl: z.number(),
maxUl: z.number(),
})
export const ServerCreateSchema = z.object({ // Request payload types for route handlers.
host: z.string().min(1, "Host is required"), // (Type-provider-zod in this repo sometimes falls back to `unknown` during cross-package schema wiring.)
port: z.number().int().positive().default(443), export type ServerIdParams = z.infer<typeof ServerIdParamSchema>
username: z.string().default("admin"), export type TestConnectionRequest = z.infer<typeof TestConnectionSchema>
password: z.string().default(""), export type SnapshotsQuery = z.infer<typeof SnapshotsQuerySchema>
useSsl: z.boolean().default(true), export type ServerCreateRequest = z.infer<typeof ServerCreateSchema>
verifySsl: z.boolean().default(false), export type ServerUpdateRequest = z.infer<typeof ServerUpdateSchema>
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),
})
// ── Response types (what the API returns) ───────────────────────────────────── // ── Response types (what the API returns) ─────────────────────────────────────
/** Flat server response — mirrors the frontend's Server interface from lib/data.ts */ /** Flat server response — mirrors the frontend's Server interface from lib/data.ts */
export interface WanUplinkRead { export type WanUplinkRead = ContractWanUplinkRead
id: string export type ServerCreate = ContractServerCreate
name: string export type ServerUpdate = ContractServerUpdate
isp: string export type ServerRead = ContractServerRead
iface: string export type SnapshotRead = ContractSnapshotRead
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
}
// ── BGP types ──────────────────────────────────────────────────────────────── // ── BGP types ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -5,7 +5,7 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"lib": ["ES2022"], "lib": ["ES2022"],
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "..",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
+39
View File
@@ -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.
- Переход через параллельный новый слой и поэтапное переключение.
+28
View File
@@ -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,
}
}
+2 -1
View File
@@ -388,7 +388,8 @@ export function buildLiveOptimizerData(
} }
if (bestAssign) { if (bestAssign) {
for (const wan of home.wans) out.set(wan.id, bestAssign.get(wan.id)) const best: Map<string, RouteOptimizerSpeedProbe> = bestAssign as Map<string, RouteOptimizerSpeedProbe>
for (const wan of home.wans) out.set(wan.id, best.get(wan.id))
return out return out
} }
+2
View File
@@ -25,6 +25,8 @@ export type AlertEngineRuleDiagSnapshot = {
ruleId: string ruleId: string
inGroup: boolean inGroup: boolean
evalHit: boolean evalHit: boolean
hitTransition?: "problem" | "recovery" | "neutral"
hitMessage?: string
stabilityOk: boolean stabilityOk: boolean
cooldownOk: boolean cooldownOk: boolean
telegramOk: boolean telegramOk: boolean
+17
View File
@@ -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"
}
}
+204
View File
@@ -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<readonly [z.ZodEnum<{
"1\u043C": "1м";
"5\u043C": "5м";
"15\u043C": "15м";
"1\u0447": "1ч";
"4\u0447": "4ч";
"24\u0447": "24ч";
}>, 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<z.ZodString>;
targets: z.ZodOptional<z.ZodArray<z.ZodString>>;
groupId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
condition: z.ZodOptional<z.ZodString>;
conditions: z.ZodOptional<z.ZodArray<z.ZodString>>;
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<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>;
recoveryMode: z.ZodOptional<z.ZodEnum<{
never: "never";
always: "always";
conditional: "conditional";
}>>;
recoveryStabilitySec: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>;
chatId: z.ZodString;
}, z.core.$strip>;
export declare const putRulesSchema: z.ZodObject<{
rules: z.ZodArray<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<z.ZodString>;
targets: z.ZodOptional<z.ZodArray<z.ZodString>>;
groupId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
condition: z.ZodOptional<z.ZodString>;
conditions: z.ZodOptional<z.ZodArray<z.ZodString>>;
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<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>;
recoveryMode: z.ZodOptional<z.ZodEnum<{
never: "never";
always: "always";
conditional: "conditional";
}>>;
recoveryStabilitySec: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>;
chatId: z.ZodString;
}, z.core.$strip>>;
groups: z.ZodOptional<z.ZodArray<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
combineMode: z.ZodEnum<{
any: "any";
all: "all";
}>;
enabled: z.ZodBoolean;
cooldownOverride: z.ZodUnion<readonly [z.ZodEnum<{
"1\u043C": "1м";
"5\u043C": "5м";
"15\u043C": "15м";
"1\u0447": "1ч";
"4\u0447": "4ч";
"24\u0447": "24ч";
}>, z.ZodNull]>;
}, z.core.$strip>>>;
}, z.core.$strip>;
export declare const putTelegramSchema: z.ZodObject<{
token: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
chatId: z.ZodOptional<z.ZodString>;
messageThreadId: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>;
}, z.core.$strip>;
export declare const rulePreviewTelegramSchema: z.ZodObject<{
name: z.ZodString;
targets: z.ZodArray<z.ZodString>;
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<z.ZodString>;
chatId: z.ZodOptional<z.ZodString>;
messageThreadId: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
rulePreview: z.ZodOptional<z.ZodObject<{
name: z.ZodString;
targets: z.ZodArray<z.ZodString>;
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<typeof alertGroupSchema>;
export type AlertRule = z.infer<typeof alertRuleSchema>;
export type PutRulesBody = z.infer<typeof putRulesSchema>;
export type PutTelegramBody = z.infer<typeof putTelegramSchema>;
export type RulePreviewTelegram = z.infer<typeof rulePreviewTelegramSchema>;
export type TestTelegramBody = z.infer<typeof testTelegramSchema>;
//# sourceMappingURL=alerts.d.ts.map
+1
View File
@@ -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"}
+71
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"alerts.js","sourceRoot":"","sources":["alerts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,CAAC,IAAI,CAAC;IACpC,YAAY;IACZ,UAAU;IACV,YAAY;IACZ,YAAY;IACZ,QAAQ;IACR,KAAK;IACL,MAAM;IACN,SAAS;CACV,CAAC,CAAA;AACF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC,CAAA;AAC1E,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAA;AACjF,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,CAAA;AAC5E,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;AAEvD,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACrB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACvB,WAAW,EAAE,iBAAiB;IAC9B,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;IACpB,gBAAgB,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,mBAAmB,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;CAC3D,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC;KAC7B,MAAM,CAAC;IACN,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACrB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACvB,IAAI,EAAE,eAAe;IACrB,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7B,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;IAC9C,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;IACzC,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAChC,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;IACjD,QAAQ,EAAE,mBAAmB;IAC7B,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;IACpB,QAAQ,EAAE,mBAAmB;IAC7B,mBAAmB,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE;IACvF,YAAY,EAAE,kBAAkB,CAAC,QAAQ,EAAE;IAC3C,oBAAoB,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE;IACxF,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;CACnB,CAAC;KACD,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,IAAI,IAAI,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE;IACvF,OAAO,EAAE,+CAA+C;IACxD,IAAI,EAAE,CAAC,SAAS,CAAC;CAClB,CAAC;KACD,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,IAAI,IAAI,IAAI,CAAC,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC,EAAE;IAChG,OAAO,EAAE,sDAAsD;IAC/D,IAAI,EAAE,CAAC,YAAY,CAAC;CACrB,CAAC,CAAA;AAEJ,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC;IACrC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,eAAe,CAAC;IAC/B,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,QAAQ,EAAE;CAC7C,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC;IACxC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE;IACjD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7B,eAAe,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE;CAC7E,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC,MAAM,CAAC;IAChD,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACvB,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC1C,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAChC,QAAQ,EAAE,mBAAmB;IAC7B,QAAQ,EAAE,mBAAmB;CAC9B,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC5B,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7B,eAAe,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;IAC9D,WAAW,EAAE,yBAAyB,CAAC,QAAQ,EAAE;CAClD,CAAC,CAAA"}
+84
View File
@@ -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<typeof alertGroupSchema>
export type AlertRule = z.infer<typeof alertRuleSchema>
export type PutRulesBody = z.infer<typeof putRulesSchema>
export type PutTelegramBody = z.infer<typeof putTelegramSchema>
export type RulePreviewTelegram = z.infer<typeof rulePreviewTelegramSchema>
export type TestTelegramBody = z.infer<typeof testTelegramSchema>
+2
View File
@@ -0,0 +1,2 @@
export * from "./servers.js"
export * from "./alerts.js"
+228
View File
@@ -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<z.ZodNumber>;
username: z.ZodDefault<z.ZodString>;
password: z.ZodDefault<z.ZodString>;
useSsl: z.ZodDefault<z.ZodBoolean>;
verifySsl: z.ZodDefault<z.ZodBoolean>;
name: z.ZodDefault<z.ZodString>;
type: z.ZodDefault<z.ZodEnum<{
"jump-host": "jump-host";
"exit-node": "exit-node";
"home-router": "home-router";
}>>;
site: z.ZodDefault<z.ZodString>;
country: z.ZodDefault<z.ZodString>;
asn: z.ZodDefault<z.ZodString>;
comment: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
lanSubnet: z.ZodDefault<z.ZodString>;
wanUplinks: z.ZodDefault<z.ZodArray<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>>>;
}, z.core.$strip>;
export declare const serverUpdateSchema: z.ZodObject<{
type: z.ZodOptional<z.ZodDefault<z.ZodEnum<{
"jump-host": "jump-host";
"exit-node": "exit-node";
"home-router": "home-router";
}>>>;
name: z.ZodOptional<z.ZodDefault<z.ZodString>>;
port: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
username: z.ZodOptional<z.ZodDefault<z.ZodString>>;
password: z.ZodOptional<z.ZodDefault<z.ZodString>>;
useSsl: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
verifySsl: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
site: z.ZodOptional<z.ZodDefault<z.ZodString>>;
country: z.ZodOptional<z.ZodDefault<z.ZodString>>;
asn: z.ZodOptional<z.ZodDefault<z.ZodString>>;
comment: z.ZodOptional<z.ZodDefault<z.ZodString>>;
enabled: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
lanSubnet: z.ZodOptional<z.ZodDefault<z.ZodString>>;
wanUplinks: z.ZodOptional<z.ZodDefault<z.ZodArray<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>>>>;
host: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export declare const serverIdParamSchema: z.ZodObject<{
id: z.ZodCoercedNumber<unknown>;
}, z.core.$strip>;
export declare const snapshotsQuerySchema: z.ZodObject<{
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
}, z.core.$strip>;
export declare const testConnectionSchema: z.ZodObject<{
host: z.ZodString;
port: z.ZodDefault<z.ZodNumber>;
useSsl: z.ZodDefault<z.ZodBoolean>;
verifySsl: z.ZodDefault<z.ZodBoolean>;
apiPath: z.ZodDefault<z.ZodString>;
username: z.ZodDefault<z.ZodString>;
password: z.ZodDefault<z.ZodString>;
}, 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<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>>;
createdAt: z.ZodString;
updatedAt: z.ZodString;
status: z.ZodUnion<readonly [z.ZodEnum<{
online: "online";
offline: "offline";
}>, z.ZodNull]>;
latency: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
os: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
model: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
uptime: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
cpuLoad: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
freeMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
totalMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
identityName: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
sessions: z.ZodNumber;
polledAt: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
}, 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<readonly [z.ZodNumber, z.ZodNull]>;
rosVersion: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
boardName: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
uptime: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
cpuLoad: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
freeMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
totalMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
identityName: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
}, z.core.$strip>;
export declare const serverListSchema: z.ZodArray<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<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>>;
createdAt: z.ZodString;
updatedAt: z.ZodString;
status: z.ZodUnion<readonly [z.ZodEnum<{
online: "online";
offline: "offline";
}>, z.ZodNull]>;
latency: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
os: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
model: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
uptime: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
cpuLoad: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
freeMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
totalMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
identityName: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
sessions: z.ZodNumber;
polledAt: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
}, z.core.$strip>>;
export declare const snapshotListSchema: z.ZodArray<z.ZodObject<{
id: z.ZodNumber;
serverId: z.ZodNumber;
polledAt: z.ZodString;
status: z.ZodEnum<{
online: "online";
offline: "offline";
}>;
latencyMs: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
rosVersion: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
boardName: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
uptime: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
cpuLoad: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
freeMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
totalMemory: z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>;
identityName: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
}, z.core.$strip>>;
export type ServerType = z.infer<typeof serverTypeSchema>;
export type WanUplink = z.infer<typeof wanUplinkSchema>;
export type ServerCreate = z.infer<typeof serverCreateSchema>;
export type ServerUpdate = z.infer<typeof serverUpdateSchema>;
export type ServerRead = z.infer<typeof serverReadSchema>;
export type SnapshotRead = z.infer<typeof snapshotReadSchema>;
export type ServerIdParam = z.infer<typeof serverIdParamSchema>;
export type SnapshotsQuery = z.infer<typeof snapshotsQuerySchema>;
export type TestConnectionRequest = z.infer<typeof testConnectionSchema>;
//# sourceMappingURL=servers.d.ts.map
+1
View File
@@ -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"}
+95
View File
@@ -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
File diff suppressed because one or more lines are too long
+114
View File
@@ -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<typeof serverTypeSchema>
export type WanUplink = z.infer<typeof wanUplinkSchema>
export type ServerCreate = z.infer<typeof serverCreateSchema>
export type ServerUpdate = z.infer<typeof serverUpdateSchema>
export type ServerRead = z.infer<typeof serverReadSchema>
export type SnapshotRead = z.infer<typeof snapshotReadSchema>
export type ServerIdParam = z.infer<typeof serverIdParamSchema>
export type SnapshotsQuery = z.infer<typeof snapshotsQuerySchema>
export type TestConnectionRequest = z.infer<typeof testConnectionSchema>
+19
View File
@@ -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"]
}
+45
View File
@@ -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<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
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
}
+65
View File
@@ -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<ServerRead[]> {
const payload = await requestJson<unknown>(baseUrl, "/api/servers")
return serverListSchema.parse(payload)
}
export async function getServer(baseUrl: string, id: string): Promise<ServerRead> {
const payload = await requestJson<unknown>(baseUrl, `/api/servers/${id}`)
return serverReadSchema.parse(payload)
}
export async function createServer(baseUrl: string, data: ServerCreate): Promise<ServerRead> {
const payload = await requestJson<unknown>(baseUrl, "/api/servers", {
method: "POST",
body: JSON.stringify(data),
})
return serverReadSchema.parse(payload)
}
export async function updateServer(baseUrl: string, id: string, data: ServerUpdate): Promise<ServerRead> {
const payload = await requestJson<unknown>(baseUrl, `/api/servers/${id}`, {
method: "PUT",
body: JSON.stringify(data),
})
return serverReadSchema.parse(payload)
}
export async function deleteServer(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/servers/${id}`, { method: "DELETE" })
}
export async function pollServer(baseUrl: string, id: string): Promise<void> {
await requestJson<unknown>(baseUrl, `/api/servers/${id}/poll`, { method: "POST" })
}
export async function testServerConnection(
baseUrl: string,
payload: TestConnectionRequest,
): Promise<TestConnectionResponse> {
testConnectionSchema.parse(payload)
return requestJson<TestConnectionResponse>(baseUrl, "/api/servers/test-connection", {
method: "POST",
body: JSON.stringify(payload),
})
}