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
@@ -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 { z } from "zod"
import {
getAlertsMeta,
getTelegramBotToken,
@@ -13,90 +12,11 @@ import {
sendTelegramAlertMessage,
updateTelegramSettings,
} from "../services/alerts-service.js"
const AlertTypeSchema = z.enum([
"gre-tunnel",
"bgp-peer",
"bgp-prefix",
"gre-client",
"server",
"rtt",
"loss",
"traffic",
])
const AlertSeveritySchema = z.enum(["critical", "warning", "info"])
const AlertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"])
const RecoveryModeSchema = z.enum(["always", "never", "conditional"])
const AlertGroupBodySchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
combineMode: z.enum(["any", "all"]),
enabled: z.boolean(),
cooldownOverride: z.union([AlertCooldownSchema, z.null()]),
})
const AlertRuleBodySchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
type: AlertTypeSchema,
target: z.string().optional(),
targets: z.array(z.string().min(1)).optional(),
groupId: z.string().nullable().optional(),
/** Сводка; если переданы `conditions`, может дублировать первую строку */
condition: z.string().optional(),
/** Несколько условий (OR); приоритет над одной строкой `condition` */
conditions: z.array(z.string().min(1)).optional(),
severity: AlertSeveritySchema,
enabled: z.boolean(),
cooldown: AlertCooldownSchema,
/** Секунды подтверждения стабильности; 0 / null / не передано — выкл. */
confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
recoveryMode: RecoveryModeSchema.optional(),
recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
chatId: z.string(),
})
.refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), {
message: "Нужен хотя бы один объект: targets или target",
path: ["targets"],
})
.refine(
(r) =>
(r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()),
{ message: "Нужно хотя бы одно условие: conditions или condition", path: ["conditions"] },
)
const PutRulesSchema = z.object({
rules: z.array(AlertRuleBodySchema),
groups: z.array(AlertGroupBodySchema).optional(),
})
const PutTelegramSchema = z.object({
/** undefined — не менять; null или "" — очистить токен */
token: z.union([z.string(), z.null()]).optional(),
chatId: z.string().optional(),
/** undefined — не менять; null — сбросить (общий чат супергруппы) */
messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(),
})
const RulePreviewTelegramSchema = z.object({
name: z.string().min(1),
targets: z.array(z.string().min(1)).min(1),
conditionLine: z.string().min(1),
severity: AlertSeveritySchema,
cooldown: AlertCooldownSchema,
})
const TestTelegramSchema = z.object({
/** Если не передан — взять сохранённый токен из БД */
token: z.string().optional(),
chatId: z.string().optional(),
/** Если не передан — из БД; для проверки черновика до сохранения */
messageThreadId: z.coerce.number().int().positive().optional(),
/** Если задан — вместо короткого текста отправляется предпросмотр правила (создание / правка). */
rulePreview: RulePreviewTelegramSchema.optional(),
})
import {
putRulesSchema,
putTelegramSchema,
testTelegramSchema,
} from "../../../packages/contracts/dist/alerts.js"
const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/alerts", async (_req, reply) => {
@@ -112,7 +32,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.put("/alerts/rules", async (req, reply) => {
const parsed = PutRulesSchema.safeParse(req.body)
const parsed = putRulesSchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
@@ -152,7 +72,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.put("/alerts/telegram", async (req, reply) => {
const parsed = PutTelegramSchema.safeParse(req.body)
const parsed = putTelegramSchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
@@ -166,7 +86,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.post("/alerts/telegram/test", async (req, reply) => {
const parsed = TestTelegramSchema.safeParse(req.body ?? {})
const parsed = testTelegramSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
+5 -3
View File
@@ -4,14 +4,15 @@ import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { parseBgpSessions } from "../services/bgp-parse-sessions.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type { BgpSessionRead } from "../types/server.js"
const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db.select().from(servers).where(eq(servers.id, req.params.id)).limit(1).all()[0]
const params = req.params as ServerIdParams
const server = db.select().from(servers).where(eq(servers.id, params.id)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
@@ -44,9 +45,10 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/bgp/sessions — single server
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.where(eq(servers.id, params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
+3 -2
View File
@@ -4,7 +4,7 @@ import { z } from "zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type {
RosIpAddress, RosInterface, RosResource, RosIdentity, RosIpRoute,
RosFirewallFilter, RosLogEntry, RosBgpSession, RosPingResult,
@@ -313,9 +313,10 @@ const execRoutes: FastifyPluginAsyncZod = async (app) => {
"/servers/:id/exec",
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.where(eq(servers.id, params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
+7 -4
View File
@@ -3,7 +3,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
@@ -630,9 +630,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/ospf — single server OSPF + BFD data
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.where(eq(servers.id, params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -657,9 +658,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
"/servers/:id/ospf/optimize/preview",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.where(eq(servers.id, params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -688,9 +690,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
"/servers/:id/ospf/optimize",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.where(eq(servers.id, params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
+36 -161
View File
@@ -1,95 +1,28 @@
import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { ServerRead, WanUplinkRead } from "../types/server.js"
import {
ServerCreateSchema,
ServerUpdateSchema,
ServerIdParamSchema,
SnapshotsQuerySchema,
TestConnectionSchema,
type ServerCreateRequest,
type ServerIdParams,
type ServerUpdateRequest,
type SnapshotsQuery,
type TestConnectionRequest,
} from "../types/server.js"
import { pollServer, toSnapshotRead } from "../services/poller.js"
import { pollServer } from "../services/poller.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
// ── helpers ────────────────────────────────────────────────────────────────────
type ServerRow = typeof servers.$inferSelect
type SnapshotRow = typeof serverSnapshots.$inferSelect
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] {
if (raw == null || raw === "") return []
try {
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
const out: WanUplinkRead[] = []
for (const row of data) {
if (typeof row !== "object" || row === null) continue
const r = row as Record<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]
}
import {
createServer,
deleteServer,
getServerReadById,
listServerSnapshots,
listServersRead,
updateServer,
} from "../modules/servers/service/servers-service.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
// ── plugin ─────────────────────────────────────────────────────────────────────
@@ -97,7 +30,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers/test-connection — check credentials before saving
app.post("/test-connection", { schema: { body: TestConnectionSchema } }, async (req, reply) => {
const { host, port, useSsl, verifySsl, apiPath, username, password } = req.body
const { host, port, useSsl, verifySsl, apiPath, username, password } = req.body as TestConnectionRequest
const client = new MikrotikClient({ host, port, useSsl, verifySsl, apiPath, username, password })
try {
@@ -144,17 +77,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers
app.get("/", async (_req, reply) => {
const all = db.select().from(servers).all()
const result: ServerRead[] = all.map(s => toServerRead(s, getLatestSnapshot(s.id)))
return reply.send(result)
return reply.send(listServersRead())
})
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
const params = req.params as ServerIdParams
const server = getServerRowById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
const ipv4 = await resolveRosSrcIpv4(server.host)
@@ -163,30 +92,15 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
const now = new Date().toISOString()
const { wanUplinks, ...rest } = req.body
const [inserted] = db
.insert(servers)
.values({
...rest,
wanUplinks: JSON.stringify(wanUplinks ?? []),
createdAt: now,
updatedAt: now,
})
.returning()
.all()
return reply.status(201).send(toServerRead(inserted, undefined))
return reply.status(201).send(createServer(req.body as ServerCreateRequest))
})
// GET /api/servers/:id
app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
const params = req.params as ServerIdParams
const server = getServerReadById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send(toServerRead(server, getLatestSnapshot(server.id)))
return reply.send(server)
})
// PUT /api/servers/:id
@@ -194,58 +108,30 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
"/:id",
{ schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } },
async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
const params = req.params as ServerIdParams
const existing = getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
const body = req.body
const { wanUplinks, ...rest } = body
const setPayload: Record<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)))
return reply.send(updateServer(params.id, req.body as ServerUpdateRequest))
},
)
// DELETE /api/servers/:id
app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
const params = req.params as ServerIdParams
const existing = getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
db.delete(servers).where(eq(servers.id, req.params.id)).run()
deleteServer(params.id)
return reply.status(204).send()
})
// POST /api/servers/:id/poll
app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
const params = req.params as ServerIdParams
const existing = getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
try {
const snap = await pollServer(req.params.id)
const snap = await pollServer(params.id)
return reply.send(snap)
} catch (err) {
return reply.status(500).send({ error: (err as Error).message })
@@ -257,22 +143,11 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
"/:id/snapshots",
{ schema: { params: ServerIdParamSchema, querystring: SnapshotsQuerySchema } },
async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
const params = req.params as ServerIdParams
const query = req.query as SnapshotsQuery
const existing = getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
const snaps = db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, req.params.id))
.orderBy(desc(serverSnapshots.polledAt))
.limit(req.query.limit)
.all()
return reply.send(snaps.map(toSnapshotRead))
return reply.send(listServerSnapshots(params.id, query.limit))
},
)
}
+29 -105
View File
@@ -1,4 +1,16 @@
import { z } from "zod"
import {
serverCreateSchema,
serverIdParamSchema,
snapshotsQuerySchema,
serverUpdateSchema,
testConnectionSchema,
type ServerRead as ContractServerRead,
type SnapshotRead as ContractSnapshotRead,
type ServerCreate as ContractServerCreate,
type ServerUpdate as ContractServerUpdate,
type WanUplink as ContractWanUplinkRead,
} from "../../../packages/contracts/dist/servers.js"
// ── RouterOS raw response types ────────────────────────────────────────────────
@@ -49,116 +61,28 @@ export interface RosIpAddress {
// ── Zod schemas for API validation ────────────────────────────────────────────
export const WanUplinkSchema = z.object({
id: z.string(),
name: z.string(),
isp: z.string(),
iface: z.string(),
ip: z.string(),
maxDl: z.number(),
maxUl: z.number(),
})
export const ServerCreateSchema = serverCreateSchema
export const ServerUpdateSchema = serverUpdateSchema
export const TestConnectionSchema = testConnectionSchema
export const ServerIdParamSchema = serverIdParamSchema
export const SnapshotsQuerySchema = snapshotsQuerySchema
export const ServerCreateSchema = z.object({
host: z.string().min(1, "Host is required"),
port: z.number().int().positive().default(443),
username: z.string().default("admin"),
password: z.string().default(""),
useSsl: z.boolean().default(true),
verifySsl: z.boolean().default(false),
name: z.string().default(""),
type: z.enum(["jump-host", "exit-node", "home-router"]).default("home-router"),
site: z.string().default(""),
country: z.string().default(""),
asn: z.string().default(""),
comment: z.string().default(""),
enabled: z.boolean().default(true),
lanSubnet: z.string().default(""),
wanUplinks: z.array(WanUplinkSchema).default([]),
})
export const ServerUpdateSchema = ServerCreateSchema.partial().omit({ host: true }).extend({
host: z.string().min(1).optional(),
})
export const TestConnectionSchema = z.object({
host: z.string().min(1),
port: z.number().int().positive().default(443),
useSsl: z.boolean().default(true),
verifySsl: z.boolean().default(false),
apiPath: z.string().default("/rest"),
username: z.string().default("admin"),
password: z.string().default(""),
})
export const ServerIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
})
export const SnapshotsQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).default(50),
})
// Request payload types for route handlers.
// (Type-provider-zod in this repo sometimes falls back to `unknown` during cross-package schema wiring.)
export type ServerIdParams = z.infer<typeof ServerIdParamSchema>
export type TestConnectionRequest = z.infer<typeof TestConnectionSchema>
export type SnapshotsQuery = z.infer<typeof SnapshotsQuerySchema>
export type ServerCreateRequest = z.infer<typeof ServerCreateSchema>
export type ServerUpdateRequest = z.infer<typeof ServerUpdateSchema>
// ── Response types (what the API returns) ─────────────────────────────────────
/** Flat server response — mirrors the frontend's Server interface from lib/data.ts */
export interface WanUplinkRead {
id: string
name: string
isp: string
iface: string
ip: string
maxDl: number
maxUl: number
}
export interface ServerRead {
id: number
name: string
host: string
port: number
useSsl: boolean
verifySsl: boolean
username: string
password: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
comment: string
enabled: boolean
lanSubnet: string
wanUplinks: WanUplinkRead[]
createdAt: string
updatedAt: string
// from latest snapshot (null if never polled)
status: "online" | "offline" | null
latency: number | null // ms
os: string | null // RouterOS version
model: string | null // board-name
uptime: string | null
cpuLoad: number | null
freeMemory: number | null
totalMemory: number | null
identityName: string | null
sessions: number // always 0 for now
polledAt: string | null
}
export interface SnapshotRead {
id: number
serverId: number
polledAt: string
status: "online" | "offline"
latencyMs: number | null
rosVersion: string | null
boardName: string | null
uptime: string | null
cpuLoad: number | null
freeMemory: number | null
totalMemory: number | null
identityName: string | null
}
export type WanUplinkRead = ContractWanUplinkRead
export type ServerCreate = ContractServerCreate
export type ServerUpdate = ContractServerUpdate
export type ServerRead = ContractServerRead
export type SnapshotRead = ContractSnapshotRead
// ── BGP types ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -5,7 +5,7 @@
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"rootDir": "..",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,