chore: synchronize pending app/backend updates and repository hygiene

Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-05-07 12:29:04 +07:00
1 parent bdb9b72fac
commit 5f31bb47fb
81 files changed
+11976 -1239

No files matched your search

+107 -2
View File
@@ -1,6 +1,7 @@
import http from "node:http"
import https from "node:https"
import type { Server } from "../db/schema.js"
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
import type {
RosIdentity, RosInterface, RosIpAddress, RosResource,
RosBgpSession,
@@ -199,6 +200,76 @@ function rosDelete(
})
}
function rosPatch(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const payload = JSON.stringify(body)
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "PATCH",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
if (buf.trim() === "") {
resolve({})
return
}
try { resolve(JSON.parse(buf)) } catch { resolve({}) }
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.write(payload)
req.end()
})
}
/**
* RouterOS REST для одиночных меню (`/system/resource`, `/system/identity`) отдаёт JSON-массив из одного объекта `[{...}]`,
* а не сам объект. Без распаковки поля читаются с массива → всегда undefined и в БД уходят нули при «успехе».
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
*/
function unwrapRestSingle<T extends object>(raw: unknown): T {
if (Array.isArray(raw)) {
const first = raw[0]
if (first && typeof first === "object") return first as T
throw new Error("RouterOS REST: пустой массив вместо записи")
}
if (raw && typeof raw === "object") return raw as T
throw new Error("RouterOS REST: неожиданное тело ответа")
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
@@ -228,14 +299,43 @@ export class MikrotikClient {
return rosDelete(this.params, path, timeoutMs)
}
async patch<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPatch(this.params, path, body, timeoutMs) as Promise<T>
}
// ── typed helpers ──────────────────────────────────────────────────────────
async getIdentity(): Promise<RosIdentity> {
return this.get<RosIdentity>("/system/identity")
const raw = await this.get<unknown>("/system/identity")
return unwrapRestSingle<RosIdentity>(raw)
}
async getResource(): Promise<RosResource> {
return this.get<RosResource>("/system/resource")
const raw = await this.get<unknown>("/system/resource")
if (Array.isArray(raw)) {
if (raw.length === 0) throw new Error("RouterOS REST: пустой массив вместо записи /system/resource")
if (raw.length === 1) return unwrapRestSingle<RosResource>(raw[0])
/** Несколько объектов: взять строку с total-memory (агрегат), а не дочернюю запись с «чужим» uptime. */
const objs = raw.filter((x): x is Record<string, unknown> => x != null && typeof x === "object" && !Array.isArray(x))
/** На части железа несколько строк; «первая с непустым total-memory» может парситься в 0 — берём запись с максимальным RAM. */
let best: Record<string, unknown> | undefined
let bestBytes = -1
for (const o of objs) {
const tm = parseRosDataSizeBytes(o["total-memory"])
const fm = parseRosDataSizeBytes(o["free-memory"])
const score = tm > 0 ? tm : fm
if (score > bestBytes) {
bestBytes = score
best = o
}
}
const main = objs.find((o) => {
const tm = o["total-memory"]
return tm != null && String(tm).trim() !== ""
})
return ((bestBytes > 0 ? best : undefined) ?? main ?? objs[0]) as unknown as RosResource
}
return unwrapRestSingle<RosResource>(raw)
}
async getInterfaces(): Promise<RosInterface[]> {
@@ -277,6 +377,11 @@ export class MikrotikClient {
return this.get<RosBfdSession[]>("/routing/bfd/session")
}
async setOspfInterfaceTemplateCost(templateId: string, cost: number): Promise<void> {
const safeId = templateId.startsWith("*") ? templateId : `*${templateId.replace(/^\*/, "")}`
await this.patch(`/routing/ospf/interface-template/${encodeURIComponent(safeId)}`, { cost: String(cost) })
}
// ── extra endpoints for exec route ────────────────────────────────────────
async getIpRoutes(): Promise<RosIpRoute[]> {