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:
@@ -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)
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"rootDir": "..",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user