Init commit

This commit is contained in:
Denozordec
2026-05-02 01:17:08 +07:00
commit f3f831653f
104 changed files with 43827 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Path to SQLite database file
DATABASE_PATH=./mikrotik.db
# Port for the Fastify server
PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.db
*.db-shm
*.db-wal
.env
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "drizzle-kit"
import { config } from "dotenv"
config()
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
},
verbose: true,
strict: true,
})
+36
View File
@@ -0,0 +1,36 @@
CREATE TABLE `server_snapshots` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`polled_at` text NOT NULL,
`status` text NOT NULL,
`latency_ms` real,
`ros_version` text,
`board_name` text,
`uptime` text,
`cpu_load` integer,
`free_memory` integer,
`total_memory` integer,
`identity_name` text,
`raw_interfaces` text,
`raw_ip_addresses` text,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `servers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text DEFAULT '' NOT NULL,
`host` text NOT NULL,
`port` integer DEFAULT 443 NOT NULL,
`username` text DEFAULT 'admin' NOT NULL,
`password` text DEFAULT '' NOT NULL,
`use_ssl` integer DEFAULT true NOT NULL,
`verify_ssl` integer DEFAULT false NOT NULL,
`type` text DEFAULT 'home-router' NOT NULL,
`site` text DEFAULT '' NOT NULL,
`country` text DEFAULT '' NOT NULL,
`asn` text DEFAULT '' NOT NULL,
`comment` text DEFAULT '' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
);
+276
View File
@@ -0,0 +1,276 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0b2f7dde-98dc-44e9-84a9-fd04e004299d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"server_snapshots": {
"name": "server_snapshots",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"polled_at": {
"name": "polled_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"latency_ms": {
"name": "latency_ms",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ros_version": {
"name": "ros_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"board_name": {
"name": "board_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"uptime": {
"name": "uptime",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cpu_load": {
"name": "cpu_load",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"free_memory": {
"name": "free_memory",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_memory": {
"name": "total_memory",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity_name": {
"name": "identity_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_interfaces": {
"name": "raw_interfaces",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_ip_addresses": {
"name": "raw_ip_addresses",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"server_snapshots_server_id_servers_id_fk": {
"name": "server_snapshots_server_id_servers_id_fk",
"tableFrom": "server_snapshots",
"tableTo": "servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"servers": {
"name": "servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"port": {
"name": "port",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 443
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'admin'"
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"use_ssl": {
"name": "use_ssl",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"verify_ssl": {
"name": "verify_ssl",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'home-router'"
},
"site": {
"name": "site",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"asn": {
"name": "asn",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1777572014210,
"tag": "0000_living_xorn",
"breakpoints": true
}
]
}
+3008
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "mikrotik-manager-backend",
"version": "0.1.0",
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { config } from "dotenv"
import { z } from "zod"
config()
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
+164
View File
@@ -0,0 +1,164 @@
import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
import { env } from "../config.js"
import * as schema from "./schema.js"
const sqlite = new Database(env.DATABASE_PATH)
// WAL mode for better concurrent read performance
sqlite.pragma("journal_mode = WAL")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(`
CREATE TABLE IF NOT EXISTS filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route',
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort
ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort
ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS traffic_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS traffic_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
rx_bps INTEGER NOT NULL DEFAULT 0,
tx_bps INTEGER NOT NULL DEFAULT 0,
running INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
ON traffic_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
ON traffic_samples(server_id, interface_name, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 15,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '—',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort
ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
probe_id TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down',
FOREIGN KEY (probe_id) REFERENCES uptime_probes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time
ON uptime_probe_samples(probe_id, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sampled_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offline',
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory INTEGER NOT NULL DEFAULT 0,
total_memory INTEGER NOT NULL DEFAULT 0,
free_hdd_space INTEGER NOT NULL DEFAULT 0,
total_hdd_space INTEGER NOT NULL DEFAULT 0,
uptime_seconds INTEGER NOT NULL DEFAULT 0,
board_name TEXT NOT NULL DEFAULT '',
ros_version TEXT NOT NULL DEFAULT '',
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
ON uptime_resource_samples(server_id, sampled_at);
`)
// Lightweight schema evolution for existing databases without migrations
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
if (!hasCountryColumn) {
sqlite.exec(`ALTER TABLE recursive_routes ADD COLUMN country TEXT NOT NULL DEFAULT ''`)
}
const uptimeProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_probes')`).all() as Array<{ name?: string }>
const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interface")
if (!hasSrcInterfaceColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
}
sqlite.exec(`
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 30, 14
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
`)
export const db = drizzle(sqlite, { schema })
+200
View File
@@ -0,0 +1,200 @@
import { sql } from "drizzle-orm"
import {
integer,
real,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core"
// ── servers ────────────────────────────────────────────────────────────────────
export const servers = sqliteTable("servers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().default(""),
host: text("host").notNull(),
port: integer("port").notNull().default(443),
username: text("username").notNull().default("admin"),
password: text("password").notNull().default(""),
useSsl: integer("use_ssl", { mode: "boolean" }).notNull().default(true),
verifySsl: integer("verify_ssl", { mode: "boolean" }).notNull().default(false),
// metadata set manually by user
type: text("type", { enum: ["jump-host", "exit-node", "home-router"] })
.notNull().default("home-router"),
site: text("site").notNull().default(""),
country: text("country").notNull().default(""),
asn: text("asn").notNull().default(""),
comment: text("comment").notNull().default(""),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── server_snapshots ───────────────────────────────────────────────────────────
export const serverSnapshots = sqliteTable("server_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
polledAt: text("polled_at").notNull(),
status: text("status", { enum: ["online", "offline"] }).notNull(),
latencyMs: real("latency_ms"),
// data from RouterOS
rosVersion: text("ros_version"),
boardName: text("board_name"),
uptime: text("uptime"),
cpuLoad: integer("cpu_load"),
freeMemory: integer("free_memory"),
totalMemory: integer("total_memory"),
identityName: text("identity_name"),
// raw JSON payloads for future use
rawInterfaces: text("raw_interfaces"),
rawIpAddresses: text("raw_ip_addresses"),
})
// ── filter_rules ───────────────────────────────────────────────────────────────
export const filterRules = sqliteTable("filter_rules", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
community: text("community").notNull(),
communityName: text("community_name"),
action: text("action", { enum: ["route", "blackhole"] }).notNull().default("route"),
gateway: text("gateway").notNull().default(""),
gatewayTunnelId: text("gateway_tunnel_id").notNull().default(""),
description: text("description").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── recursive_routes ───────────────────────────────────────────────────────────
export const recursiveRoutes = sqliteTable("recursive_routes", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
dstAddress: text("dst_address").notNull(),
gateway: text("gateway").notNull(),
distance: integer("distance").notNull().default(1),
scope: integer("scope"),
targetScope: integer("target_scope"),
routingTable: text("routing_table").notNull().default("main"),
checkGateway: text("check_gateway").notNull().default(""),
country: text("country").notNull().default(""),
comment: text("comment").notNull().default(""),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── traffic collection settings ────────────────────────────────────────────────
export const trafficSettings = sqliteTable("traffic_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(30),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
export const trafficSamples = sqliteTable("traffic_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
sampledAt: text("sampled_at").notNull(),
rxBytes: integer("rx_bytes").notNull().default(0),
txBytes: integer("tx_bytes").notNull().default(0),
rxBps: integer("rx_bps").notNull().default(0),
txBps: integer("tx_bps").notNull().default(0),
running: integer("running", { mode: "boolean" }).notNull().default(false),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
})
// ── uptime monitor settings ────────────────────────────────────────────────────
export const uptimeSettings = sqliteTable("uptime_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(15),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const uptimeProbes = sqliteTable("uptime_probes", {
id: text("id").primaryKey(),
srcServerId: integer("src_server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
srcInterface: text("src_interface").notNull().default(""),
name: text("name").notNull(),
target: text("target").notNull(),
probeFilter: text("probe_filter").notNull().default("—"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const uptimeProbeSamples = sqliteTable("uptime_probe_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
probeId: text("probe_id")
.notNull()
.references(() => uptimeProbes.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
rttMs: integer("rtt_ms"),
lossPct: integer("loss_pct").notNull().default(0),
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
})
export const uptimeResourceSamples = sqliteTable("uptime_resource_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
status: text("status", { enum: ["online", "offline"] }).notNull().default("offline"),
cpuLoad: integer("cpu_load").notNull().default(0),
freeMemory: integer("free_memory").notNull().default(0),
totalMemory: integer("total_memory").notNull().default(0),
freeHddSpace: integer("free_hdd_space").notNull().default(0),
totalHddSpace: integer("total_hdd_space").notNull().default(0),
uptimeSeconds: integer("uptime_seconds").notNull().default(0),
boardName: text("board_name").notNull().default(""),
rosVersion: text("ros_version").notNull().default(""),
})
// ── inferred types ─────────────────────────────────────────────────────────────
export type Server = typeof servers.$inferSelect
export type ServerInsert = typeof servers.$inferInsert
export type Snapshot = typeof serverSnapshots.$inferSelect
export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
export type UptimeProbeRow = typeof uptimeProbes.$inferSelect
export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
+68
View File
@@ -0,0 +1,68 @@
import Fastify from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import uptimeRoutes from "./routes/uptime.js"
import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js"
import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js"
// ── app factory ────────────────────────────────────────────────────────────────
const app = Fastify({
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
})
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
restartTrafficCollector()
void collectTrafficOnce()
restartUptimeCollector()
void collectUptimeOnce()
app.addHook("onClose", async () => {
stopTrafficCollector()
stopUptimeCollector()
})
// ── start ──────────────────────────────────────────────────────────────────────
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+208
View File
@@ -0,0 +1,208 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-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 type { RosBgpSession, BgpSessionRead } from "../types/server.js"
type ServerRow = typeof servers.$inferSelect
/** Parse RouterOS duration like "1m30s", "1d5h13m23s580ms", "30s" → seconds */
function parseDuration(s: string | undefined): number {
if (!s) return 0
let total = 0
const matches = s.matchAll(/(\d+)(w|d|h|m(?!s)|s|ms)/g)
for (const m of matches) {
const n = parseInt(m[1])
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
// ms ignored for seconds precision
}
}
return total
}
/**
* Map RouterOS 7.x short capability codes to human-readable names.
* Both local and remote capability strings are comma-separated short codes.
*/
function parseCapabilities(capStr: string | undefined): string[] {
if (!capStr) return []
const codeMap: Record<string, string> = {
mp: "MP-BGP",
rr: "Route Refresh",
gr: "Graceful Restart",
as4: "4-byte-AS",
enhe: "Extended Next-Hop",
role: "BGP Role",
err: "Extended Route Refresh",
llgr: "Long-Lived GR",
"add-path": "ADD-PATH",
}
return capStr.split(",")
.map(c => c.trim().toLowerCase())
.filter(Boolean)
.map(c => codeMap[c] ?? c)
.filter((v, i, a) => a.indexOf(v) === i) // deduplicate
}
/**
* Determine BGP session state from a RouterOS 7.x session object.
*
* RouterOS 7.x does NOT include a "state" field.
* Instead: "established": "true" means the session is up.
* For non-established sessions the "state" field may appear (RouterOS 6.x compat),
* or we fall back by checking other indicators.
*/
function detectState(s: RosBgpSession): string {
// RouterOS 7.x: explicit established flag
if (s["established"] === "true") return "Established"
// RouterOS 6.x / some 7.x builds: "state" field
if (s["state"]) {
const map: Record<string, string> = {
established: "Established",
active: "Active",
idle: "Idle",
connect: "Connect",
opensent: "OpenSent",
openconfirm: "OpenConfirm",
"open sent": "OpenSent",
"open confirm": "OpenConfirm",
}
const key = s["state"].trim().toLowerCase()
if (map[key]) return map[key]
// partial match
for (const [k, v] of Object.entries(map)) {
if (key.startsWith(k)) return v
}
return key.charAt(0).toUpperCase() + key.slice(1)
}
// Has uptime but no established flag → assume degraded Active
if (s["uptime"]) return "Active"
return "Idle"
}
function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[] {
return raw.map((s, idx) => {
const remoteAs = parseInt(s["remote.as"] ?? "0") || 0
const localAs = parseInt(s["local.as"] ?? "0") || 0
const type: "eBGP" | "iBGP" = (localAs > 0 && localAs === remoteAs) ? "iBGP" : "eBGP"
// Capabilities: prefer local caps (what we negotiated); merge with remote
const localCaps = parseCapabilities(s["local.capabilities"])
const remoteCaps = parseCapabilities(s["remote.capabilities"])
// Old-style boolean capability fields (RouterOS 6.x)
const legacyCaps: string[] = []
if (s["4-octet-as-capability"] === "true") legacyCaps.push("4-byte-AS")
if (s["as4-capability"] === "true") legacyCaps.push("4-byte-AS")
if (s["refresh-capability"] === "true") legacyCaps.push("Route Refresh")
if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH")
if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart")
if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages")
const caps = [...new Set([...localCaps, ...legacyCaps])]
// Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds)
const holdTime = parseDuration(s["hold-time"])
|| parseInt(s["active-holdtime"] ?? "0")
|| 90
// Keepalive: may have "s" suffix
const keepalive = parseDuration(s["keepalive-time"])
|| 30
// Message counts: RouterOS 7 uses "remote.messages" / "local.messages"
const inputMessages = parseInt(s["remote.messages"] ?? s["total-messages-received"] ?? "0") || 0
const outputMessages = parseInt(s["local.messages"] ?? s["total-messages-sent"] ?? "0") || 0
const peerIp = (s["remote.address"] ?? "").replace(/\/\d+$/, "")
return {
id: s[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
name: s["name"] ?? peerIp,
peerIp,
remoteAs,
localAs,
localId: s["local.id"] ?? "",
remoteId: s["remote.id"] ?? "",
state: detectState(s),
type,
uptime: s["uptime"] || null,
holdTime,
keepalive,
prefixesRx: parseInt(s["prefix-count"] ?? s["total-updates-received"] ?? "0") || 0,
prefixesTx: parseInt(s["total-updates-sent"] ?? "0") || 0,
inputMessages,
outputMessages,
capabilities: caps,
lastError: s["last-notification"] || null,
}
})
}
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]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessionsRaw()
app.log.info({ serverId: server.id, host: server.host, raw }, "BGP raw response")
return reply.send({ server: { id: server.id, host: server.host, name: server.name }, raw })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
// GET /api/bgp/sessions — aggregate from ALL enabled servers
app.get("/bgp/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results: BgpSessionRead[][] = await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessions()
return parseSessions(server, raw)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/servers/:id/bgp/sessions — single server
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessions()
return reply.send(parseSessions(server, raw))
} catch {
return reply.send([])
}
})
}
export default bgpRoutes
+336
View File
@@ -0,0 +1,336 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
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 type {
RosIpAddress, RosInterface, RosResource, RosIdentity, RosIpRoute,
RosFirewallFilter, RosLogEntry, RosBgpSession, RosPingResult,
RosOspfNeighbor, RosOspfArea, RosOspfInstance, RosOspfInterfaceTemplate,
RosBfdSession,
} from "../types/server.js"
// ── string helpers ─────────────────────────────────────────────────────────────
function p(s: string | undefined, len: number): string {
return (s ?? "").padEnd(len)
}
function flag(val: string | undefined, char: string): string {
return val === "true" ? char : " "
}
// ── formatters ─────────────────────────────────────────────────────────────────
function fmtIpAddresses(addrs: RosIpAddress[]): string {
const hdr = [
"Flags: X - disabled, I - invalid, D - dynamic",
" # ADDRESS NETWORK INTERFACE",
]
const rows = addrs.map((a, i) => {
const f = `${flag(a.disabled,"X")}${flag(a.invalid,"I")}${flag(a.dynamic,"D")}`
return ` ${String(i).padStart(2)} ${f} ${p(a.address,18)} ${p(a.network,15)} ${a.interface}${a.comment ? ` ; ${a.comment}` : ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtInterfaces(ifaces: RosInterface[]): string {
const hdr = [
"Flags: X - disabled, D - dynamic, R - running",
" # NAME TYPE MTU MAC-ADDRESS",
]
const rows = ifaces.map((f, i) => {
const flags = `${flag(f.disabled,"X")} ${flag(f.running,"R")}`
const name = p(f.name, 20)
const type = p(f.type, 12)
const mtu = p(f["actual-mtu"] ?? f.mtu, 6)
const mac = f["mac-address"] ?? ""
return ` ${String(i).padStart(2)} ${flags} ${name} ${type} ${mtu} ${mac}${f.comment ? ` ; ${f.comment}` : ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtResource(r: RosResource, id: RosIdentity): string {
const kv = (k: string, v: string) => `${k.padStart(30)}: ${v}`
const mib = (b: string | undefined) => b ? `${(parseInt(b) / 1024 / 1024).toFixed(1)} MiB` : "?"
return [
kv("uptime", r["uptime"] ?? "?"),
kv("version", r["version"] ?? "?"),
kv("build-time", r["build-time"] ?? "?"),
kv("free-memory", mib(r["free-memory"])),
kv("total-memory", mib(r["total-memory"])),
kv("cpu", r["cpu"] ?? "?"),
kv("cpu-count", r["cpu-count"] ?? "?"),
kv("cpu-frequency", r["cpu-frequency"] ? `${r["cpu-frequency"]} MHz` : "?"),
kv("cpu-load", r["cpu-load"] ? `${r["cpu-load"]}%` : "?"),
kv("free-hdd-space", mib(r["free-hdd-space"])),
kv("total-hdd-space", mib(r["total-hdd-space"])),
kv("architecture-name",r["architecture-name"] ?? "?"),
kv("board-name", r["board-name"] ?? "?"),
kv("platform", r["platform"] ?? "MikroTik"),
kv("identity", id.name),
].join("\n")
}
function fmtIpRoutes(routes: RosIpRoute[]): string {
const hdr = [
"Flags: X - disabled, A - active, D - dynamic, C - connect, S - static,",
" r - rip, b - bgp, o - ospf, B - blackhole, U - unreachable, P - prohibit",
" # DST-ADDRESS PREF-SRC GATEWAY DIST",
]
const rows = routes.map((r, i) => {
const f = [
flag(r.active, "A"),
flag(r.dynamic, "D"),
flag(r.connect, "C"),
flag(r.static, "S"),
flag(r.bgp, "b"),
flag(r.ospf, "o"),
flag(r.blackhole, "B"),
flag(r.unreachable, "U"),
flag(r.prohibit, "P"),
].join("")
const dst = p(r["dst-address"] ?? "", 18)
const pref = p(r["pref-src"] ?? "", 15)
const gw = p(r["gateway"] ?? r["interface"] ?? "", 16)
const dist = r["distance"] ?? "?"
return ` ${String(i).padStart(2)} ${f} ${dst} ${pref} ${gw} ${dist}`
})
return [...hdr, ...rows].join("\n")
}
function fmtBgpSessions(sessions: RosBgpSession[]): string {
const hdr = [
"Flags: E - established",
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME",
]
const rows = sessions.map((s, i) => {
const est = s["established"] === "true" || s["state"] === "established"
const fl = est ? "E" : " "
const name = p(s["name"] ?? s[".id"], 23)
const remAs = p(s["remote.as"], 10)
const remIp = p((s["remote.address"] ?? "").split("/")[0], 22)
const state = est ? "established" : (s["state"] ?? "active")
const up = s["uptime"] ?? "—"
return ` ${String(i).padStart(2)} ${fl} ${name} ${remAs} ${remIp} ${p(state, 12)} ${up}`
})
return [...hdr, ...rows].join("\n")
}
function fmtOspfNeighbors(neighbors: RosOspfNeighbor[]): string {
const hdr = [
"Flags: V - virtual",
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE",
]
const rows = neighbors.map((n, i) => {
return ` ${String(i).padStart(2)} ${p(n["router-id"],16)} ${p(n["state"],8)} ${p(n["state-changes"] ?? "0",8)} ${p(n["adjacency"] ?? "—",15)} ${n["interface"] ?? ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtOspfAreas(areas: RosOspfArea[]): string {
const hdr = [" # NAME AREA-ID TYPE INSTANCE"]
const rows = areas.map((a, i) =>
` ${String(i).padStart(2)} ${p(a.name,20)} ${p(a["area-id"] ?? "0.0.0.0",15)} ${p(a.type ?? "default",12)} ${a.instance}`
)
return [...hdr, ...rows].join("\n")
}
function fmtOspfInstances(insts: RosOspfInstance[]): string {
const hdr = [" # NAME ROUTER-ID VER DISTRIBUTE"]
const rows = insts.map((inst, i) =>
` ${String(i).padStart(2)} ${p(inst.name,15)} ${p(inst["router-id"],18)} ${p(inst.version ?? "2",4)} ${inst["redistribute"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtOspfIfaceTemplates(templates: RosOspfInterfaceTemplate[]): string {
const hdr = [" # INTERFACES AREA COST TYPE BFD"]
const rows = templates.map((t, i) =>
` ${String(i).padStart(2)} ${p(t.interfaces ?? "—",21)} ${p(t.area,19)} ${p(t.cost ?? "10",5)} ${p(t.type ?? "broadcast",10)} ${t["use-bfd"] === "true" ? "yes" : "no"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtBfdSessions(sessions: RosBfdSession[]): string {
const hdr = [" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME"]
const rows = sessions.map((s, i) =>
` ${String(i).padStart(2)} ${p(s["local-address"],27)} ${p(s["remote-address"],27)} ${p(s["state"] ?? "down",8)} ${s["uptime"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtFirewallFilter(rules: RosFirewallFilter[]): string {
const lines: string[] = ["Flags: X - disabled, I - invalid, D - dynamic"]
rules.forEach((r, i) => {
const f = `${flag(r.disabled,"X")}${flag(r.invalid,"I")}${flag(r.dynamic,"D")}`
lines.push(``)
lines.push(` ${String(i).padStart(2)} ${f}`)
if (r.comment) lines.push(` ;;; ${r.comment}`)
const parts = [`chain=${r.chain}`, `action=${r.action}`]
if (r.protocol) parts.push(`protocol=${r.protocol}`)
if (r["connection-state"])parts.push(`connection-state=${r["connection-state"]}`)
if (r["src-address"]) parts.push(`src-address=${r["src-address"]}`)
if (r["dst-address"]) parts.push(`dst-address=${r["dst-address"]}`)
if (r["src-address-list"])parts.push(`src-address-list=${r["src-address-list"]}`)
if (r["dst-address-list"])parts.push(`dst-address-list=${r["dst-address-list"]}`)
if (r["src-port"]) parts.push(`src-port=${r["src-port"]}`)
if (r["dst-port"]) parts.push(`dst-port=${r["dst-port"]}`)
if (r["in-interface"]) parts.push(`in-interface=${r["in-interface"]}`)
if (r["out-interface"]) parts.push(`out-interface=${r["out-interface"]}`)
if (r["tls-host"]) parts.push(`tls-host=${r["tls-host"]}`)
lines.push(` ${parts.join(" ")}`)
if (r.packets || r.bytes) lines.push(` packets=${r.packets ?? "0"} bytes=${r.bytes ?? "0"}`)
})
return lines.join("\n")
}
function fmtLogs(entries: RosLogEntry[]): string {
if (entries.length === 0) return "(no log entries)"
return entries.map(e =>
`${(e.time ?? "").padEnd(20)} ${(e.topics ?? "").padEnd(28)} ${e.message ?? ""}`
).join("\n")
}
function fmtPing(results: RosPingResult[], host: string): string {
const lines = [`PING ${host}`]
// individual replies
for (const r of results) {
if (!r.time && !r.ttl) continue // skip summary-only items
if (r.status === "timeout") {
lines.push(` seq=${r.seq} timeout`)
} else {
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time ?? "?"}`)
}
}
// summary — last item with sent/received fields
const sum = [...results].reverse().find(r => r.sent)
if (sum) {
const loss = sum["packet-loss"] ?? "0%"
lines.push(` sent=${sum.sent} received=${sum.received} packet-loss=${loss}`)
if (sum.time) lines.push(` avg-rtt=${sum.time}`)
}
return lines.join("\n")
}
// ── help text ──────────────────────────────────────────────────────────────────
const HELP_TEXT = [
"Доступные команды (RouterOS REST API):",
" ip address print — IP-адреса",
" ip route print — таблица маршрутов",
" interface print — интерфейсы",
" system resource print — ресурсы системы",
" system identity print — имя роутера",
" ip firewall filter print — правила firewall",
" routing bgp session print — BGP-сессии",
" routing ospf neighbor print — OSPF-соседи",
" routing ospf area print — OSPF-зоны",
" routing ospf instance print — OSPF-инстансы",
" routing ospf interface print — OSPF-интерфейсы",
" routing bfd session print — BFD-сессии",
" log print [limit=N] — системный лог",
" ping <host> [count=N] — пинг",
].join("\n")
// ── command dispatcher ─────────────────────────────────────────────────────────
async function dispatch(client: MikrotikClient, raw: string): Promise<string> {
// strip leading slashes, normalize whitespace
const cmd = raw.trim().replace(/^\/+/, "")
const lower = cmd.toLowerCase().replace(/\s+/g, " ")
if (lower === "?" || lower === "help") return HELP_TEXT
if (lower === "ip address print")
return fmtIpAddresses(await client.getIpAddresses())
if (lower === "interface print")
return fmtInterfaces(await client.getInterfaces())
if (lower === "system resource print") {
const [res, id] = await Promise.all([client.getResource(), client.getIdentity()])
return fmtResource(res, id)
}
if (lower === "system identity print")
return ` name: ${(await client.getIdentity()).name}`
if (lower === "ip route print")
return fmtIpRoutes(await client.getIpRoutes())
if (lower === "ip firewall filter print")
return fmtFirewallFilter(await client.getFirewallFilters())
if (lower === "routing bgp session print" || lower === "bgp session print" || lower === "bgp peer print")
return fmtBgpSessions(await client.getBgpSessions())
if (lower === "routing ospf neighbor print" || lower === "ospf neighbor print")
return fmtOspfNeighbors(await client.getOspfNeighbors())
if (lower === "routing ospf area print" || lower === "ospf area print")
return fmtOspfAreas(await client.getOspfAreas())
if (lower === "routing ospf instance print" || lower === "ospf instance print")
return fmtOspfInstances(await client.getOspfInstances())
if (lower === "routing ospf interface print" || lower === "routing ospf interface-template print")
return fmtOspfIfaceTemplates(await client.getOspfInterfaceTemplates())
if (lower === "routing bfd session print" || lower === "bfd session print")
return fmtBfdSessions(await client.getBfdSessions().catch(() => []))
if (lower.startsWith("log print")) {
const limitArg = lower.match(/limit=(\d+)/)
const limit = limitArg ? parseInt(limitArg[1]) : 50
return fmtLogs(await client.getLogs(Math.min(limit, 200)))
}
if (lower.startsWith("ping")) {
const parts = cmd.split(/\s+/)
const host = parts[1]
if (!host) return "Usage: ping <address> [count=N]"
const countArg = parts.find(p => p.toLowerCase().startsWith("count="))
const count = countArg ? parseInt(countArg.split("=")[1]) : 4
const results = await client.ping(host, Math.min(count, 20))
return fmtPing(results, host)
}
const firstToken = cmd.split(" ")[0]
return `bad command name ${firstToken} (line 1 column 1)`
}
// ── route plugin ───────────────────────────────────────────────────────────────
const ExecBodySchema = z.object({
command: z.string().min(1).max(1024),
})
const execRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers/:id/exec — execute a RouterOS CLI-style command via REST API
app.post(
"/servers/:id/exec",
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const client = MikrotikClient.fromServer(server)
try {
const output = await dispatch(client, req.body.command)
return reply.send({ output })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Return as output (not HTTP error) so the terminal can display it
return reply.send({ output: `error: ${msg}` })
}
}
)
}
export default execRoutes
+336
View File
@@ -0,0 +1,336 @@
import { and, asc, eq, inArray } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { filterRules, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
type ServerRow = typeof servers.$inferSelect
function parseServerId(raw: unknown): number | null {
const direct = Number.parseInt(String(raw ?? ""), 10)
if (Number.isFinite(direct)) return direct
const fromLegacy = String(raw ?? "").match(/(\d+)/)?.[1]
if (!fromLegacy) return null
const parsed = Number.parseInt(fromLegacy, 10)
return Number.isFinite(parsed) ? parsed : null
}
interface RosGre {
".id"?: string
name?: string
"local-address"?: string
"remote-address"?: string
"allow-fast-path"?: string
"clamp-tcp-mss"?: string
mtu?: string
"keepalive"?: string
dscp?: string
running?: string
disabled?: string
comment?: string
}
interface RosFilterRule {
".id"?: string
chain?: string
rule?: string
comment?: string
disabled?: string
}
interface LiveGreTunnel {
id: string
name: string
serverId: string
localAddress: string
remoteAddress: string
localInnerIp: string
remoteInnerIp: string
poolId: string
ipsec: null
mtu: number
keepaliveInterval: number
keepaliveRetries: number
dscp: "inherit" | number
clampTcpMss: boolean
allowFastPath: boolean
comment: string
enabled: boolean
status: "up" | "down" | "degraded"
}
interface ApiFilterRule {
id: string
community: string
communityName?: string
action?: "route" | "blackhole"
gateway: string
gatewayTunnelId: string
description: string
}
function parseKeepalive(value: string | undefined): { interval: number; retries: number } {
if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 }
const [intervalRaw, retriesRaw] = value.split(",")
const interval = Number.parseInt((intervalRaw ?? "").trim(), 10)
const retries = Number.parseInt((retriesRaw ?? "").trim(), 10)
return {
interval: Number.isFinite(interval) ? interval : 0,
retries: Number.isFinite(retries) ? retries : 0,
}
}
function parseDscp(value: string | undefined): "inherit" | number {
if (!value || value === "inherit") return "inherit"
const n = Number.parseInt(value, 10)
return Number.isFinite(n) ? n : "inherit"
}
function parseInnerIps(rule: string | undefined): { localInnerIp: string; remoteInnerIp: string } {
if (!rule) return { localInnerIp: "", remoteInnerIp: "" }
const local = rule.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
const remote = rule.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
return { localInnerIp: local, remoteInnerIp: remote }
}
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
const text = raw.rule ?? ""
// RouterOS rule formats vary across versions/config styles:
// - bgp-communities.has("AS:NNN")
// - bgp-communities includes AS:NNN
const communities = [
...text.matchAll(/bgp-communities\.has\("([^"]+)"\)/gi),
...text.matchAll(/bgp-communities\s+includes\s+([0-9]+:[0-9]+)/gi),
].map(m => m[1]).filter(Boolean)
if (communities.length === 0) return []
const uniqueCommunities = [...new Set(communities)]
const isBlackhole = /set\s+type\s+blackhole/i.test(text)
const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
return uniqueCommunities.map((community, idx) => ({
id: `${raw[".id"] ?? "live"}-${idx}`,
community,
action: isBlackhole ? "blackhole" : "route",
gateway: isBlackhole ? "" : gwToken,
gatewayTunnelId: isBlackhole ? "" : outIface,
description: inlineComment || raw.comment?.trim() || "",
}))
}
async function fetchServerFilters(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [greRaw, filterRaw] = await Promise.all([
client.get<RosGre[]>("/interface/gre"),
client.get<RosFilterRule[]>("/routing/filter/rule"),
])
const tunnels: LiveGreTunnel[] = greRaw.map((g, idx) => {
const keepalive = parseKeepalive(g["keepalive"])
const inner = parseInnerIps(g.comment)
const running = g.running === "true"
const disabled = g.disabled === "true"
return {
id: g.name || g[".id"] || `gre-${server.id}-${idx}`,
name: g.name || `gre-${idx + 1}`,
serverId: String(server.id),
localAddress: g["local-address"] ?? "",
remoteAddress: g["remote-address"] ?? "",
localInnerIp: inner.localInnerIp,
remoteInnerIp: inner.remoteInnerIp,
poolId: "live",
ipsec: null,
mtu: Number.parseInt(g.mtu ?? "1476", 10) || 1476,
keepaliveInterval: keepalive.interval,
keepaliveRetries: keepalive.retries,
dscp: parseDscp(g.dscp),
clampTcpMss: g["clamp-tcp-mss"] !== "false",
allowFastPath: g["allow-fast-path"] !== "false",
comment: g.comment ?? "",
enabled: !disabled,
status: disabled ? "down" : (running ? "up" : "degraded"),
}
})
const rules = filterRaw
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
.flatMap(parseFilterRule)
return {
serverId: String(server.id),
rules,
tunnels,
}
}
function toApiRulesets(serverRows: ServerRow[]) {
const dbRules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
return serverRows.map(s => ({
serverId: String(s.id),
rules: dbRules
.filter(r => r.serverId === s.id)
.map((r): ApiFilterRule => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
})),
}))
}
function toRouterRuleBody(rules: ApiFilterRule[]): string {
return rules.map((rule, i) => {
const kw = i === 0 ? "if" : "} else if"
const comment = rule.description ? ` # ${rule.description}` : ""
if (rule.action === "blackhole") {
return [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
" set type blackhole;",
" accept;",
].filter(Boolean).join("\n")
}
return [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
` set gateway ${rule.gateway};`,
` set out-interface ${rule.gatewayTunnelId};`,
" accept;",
].filter(Boolean).join("\n")
}).join("\n")
}
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
db.delete(filterRules).where(eq(filterRules.serverId, serverId)).run()
if (rules.length === 0) return
const now = new Date().toISOString()
db.insert(filterRules).values(
rules.map((r, i) => ({
serverId,
sortOrder: i,
community: r.community,
communityName: r.communityName ?? null,
action: r.action ?? "route",
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
createdAt: now,
updatedAt: now,
})),
).run()
}
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/filters/rules", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const dbRulesets = toApiRulesets(allServers)
const results = await Promise.all(allServers.map(async (server) => {
try {
const remote = await fetchServerFilters(server)
return { serverId: String(server.id), tunnels: remote.tunnels }
} catch {
return { serverId: String(server.id), tunnels: [] as LiveGreTunnel[] }
}
}))
return reply.send({
rulesets: dbRulesets,
greTunnels: results.flatMap(r => r.tunnels),
})
})
app.put("/filters/rules", async (req, reply) => {
const body = req.body as { rulesets?: Array<{ serverId: string; rules: ApiFilterRule[] }> }
const payload = body.rulesets ?? []
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
if (serverIds.length > 0) {
db.delete(filterRules).where(inArray(filterRules.serverId, serverIds)).run()
}
for (const rs of payload) {
const sid = Number.parseInt(rs.serverId, 10)
if (!Number.isFinite(sid)) continue
await replaceDbRules(sid, rs.rules ?? [])
}
return reply.send({ ok: true })
})
app.post("/filters/sync/from-router", async (_req, reply) => {
const body = _req.body as { serverId?: string | number } | undefined
const rawServerId = body?.serverId
const serverId = parseServerId(rawServerId)
if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" })
}
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
const remote = await fetchServerFilters(server)
await replaceDbRules(server.id, remote.rules)
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
return reply.status(500).send({ error: String(err) })
}
})
app.post("/filters/sync/to-router", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
let updatedServers = 0
let pushedRules = 0
for (const server of allServers) {
try {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:"))
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/routing/filter/rule/${encodeURIComponent(r[".id"])}`)
}
const rows = db.select().from(filterRules)
.where(and(eq(filterRules.serverId, server.id)))
.orderBy(asc(filterRules.sortOrder))
.all()
const rules: ApiFilterRule[] = rows.map(r => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
if (rules.length > 0) {
const ruleBody = toRouterRuleBody(rules)
await client.post("/routing/filter/rule", {
chain: "bgp-in",
comment: `RouterLists: ${server.name || server.host}`,
rule: ruleBody,
})
pushedRules += rules.length
}
updatedServers += 1
} catch {
// ignore failed server push
}
}
return reply.send({ ok: true, updatedServers, pushedRules })
})
}
export default filtersRoutes
+327
View File
@@ -0,0 +1,327 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-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 type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
} from "../types/server.js"
type ServerRow = typeof servers.$inferSelect
// ── helpers ───────────────────────────────────────────────────────────────────
/** Parse RouterOS duration string like "1h30m17s", "40s", "10m" → seconds */
function parseDuration(s: string | undefined): number {
if (!s) return 0
let total = 0
for (const m of s.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
const n = parseInt(m[1])
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
}
}
return total
}
/** Build area-name → area-id lookup from a list of raw areas */
function buildAreaMap(areas: RosOspfArea[]): Map<string, string> {
const map = new Map<string, string>()
for (const a of areas) {
map.set(a.name, a["area-id"] ?? "0.0.0.0")
}
return map
}
/** Parse "200ms" | "1s" | "500ms" → milliseconds */
function parseMs(s: string | undefined): number {
if (!s) return 0
const ms = s.match(/^(\d+)ms$/)
if (ms) return parseInt(ms[1])
const sec = s.match(/^(\d+)s$/)
if (sec) return parseInt(sec[1]) * 1000
return parseInt(s) || 0
}
/** Normalize BFD state: "up" → "Up", "down" → "Down", etc. */
function normalizeBfdState(state: string | undefined): string {
const map: Record<string, string> = {
up: "Up", down: "Down", init: "Init", admindown: "AdminDown",
}
return map[(state ?? "").toLowerCase()] ?? (state ?? "Down")
}
/** Extract IP and interface from RouterOS address format "10.0.0.1%eth0" */
function parseAddrIface(addr: string): { ip: string; iface: string } {
const [ip, iface = ""] = addr.split("%")
return { ip, iface }
}
/** Fetch all OSPF + BFD data for one server */
async function fetchServerOspf(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [neighbors, areas, ifaceTemplates, instances, bfdSessions] = await Promise.all([
client.getOspfNeighbors(),
client.getOspfAreas(),
client.getOspfInterfaceTemplates(),
client.getOspfInstances(),
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
])
return { neighbors, areas, ifaceTemplates, instances, bfdSessions }
}
// ── BFD parser ────────────────────────────────────────────────────────────────
function parseBfdSessions(server: ServerRow, raw: RosBfdSession[]): BfdSessionRead[] {
return raw.map((s, idx) => {
const local = parseAddrIface(s["local-address"])
const remote = parseAddrIface(s["remote-address"])
// Prefer interface from local-address; fallback to remote-address suffix
const iface = local.iface || remote.iface
return {
id: s[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
localAddr: local.ip,
remoteAddr: remote.ip,
interface: iface,
state: normalizeBfdState(s["state"]),
uptime: s["uptime"] ?? null,
multihop: s["multihop"] === "true",
multiplier: parseInt(s["multiplier"] ?? "3") || 3,
txInterval: parseMs(s["actual-tx-interval"] ?? s["desired-tx-interval"]),
rxInterval: parseMs(s["required-min-rx"]),
holdTime: parseMs(s["hold-time"]),
packetsRx: parseInt(s["packets-rx"] ?? "0") || 0,
packetsTx: parseInt(s["packets-tx"] ?? "0") || 0,
stateChanges: parseInt(s["state-changes"] ?? "0") || 0,
}
})
}
// ── parsers ───────────────────────────────────────────────────────────────────
function parseNeighbors(
server: ServerRow,
neighbors: RosOspfNeighbor[],
areaMap: Map<string, string>,
): OspfNeighborRead[] {
return neighbors.map((n, idx) => ({
id: n[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
address: n.address,
routerId: n["router-id"],
instance: n.instance,
area: n.area,
areaId: areaMap.get(n.area) ?? n.area,
interface: n.interface,
state: n.state,
uptime: n.adjacency ?? null,
stateChanges: parseInt(n["state-changes"] ?? "0") || 0,
priority: parseInt(n.priority ?? "1") || 1,
}))
}
function parseInterfaces(
server: ServerRow,
templates: RosOspfInterfaceTemplate[],
areas: RosOspfArea[],
instances: RosOspfInstance[],
areaMap: Map<string, string>,
): OspfInterfaceRead[] {
// Build instance-id → instance name map (for interface-template instance-id field)
const instanceIdMap = new Map<string, string>()
instances.forEach((inst, i) => { instanceIdMap.set(String(i), inst.name) })
return templates.map((t, idx) => {
// Resolve interface name: may be a "*ID" reference (RouterOS internal ID)
const ifaceName = (t.interfaces ?? "").startsWith("*")
? `(ref ${t.interfaces})`
: (t.interfaces ?? "—")
return {
id: t[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
instance: instanceIdMap.get(t["instance-id"] ?? "") ?? t["instance-id"] ?? "",
area: t.area,
areaId: areaMap.get(t.area) ?? t.area,
interface: ifaceName,
cost: parseInt(t.cost ?? "10") || 10,
type: t.type ?? "broadcast",
disabled: t.disabled === "true",
inactive: t.inactive === "true",
priority: parseInt(t.priority ?? "1") || 1,
helloInterval: parseDuration(t["hello-interval"]),
deadInterval: parseDuration(t["dead-interval"]),
useBfd: t["use-bfd"] === "true",
}
})
}
function parseInstances(
server: ServerRow,
instances: RosOspfInstance[],
): OspfInstanceRead[] {
return instances.map((inst, idx) => ({
id: inst[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
name: inst.name,
routerId: inst["router-id"],
version: parseInt(inst.version ?? "2") || 2,
disabled: inst.disabled === "true",
inactive: inst.inactive === "true",
redistribute: inst.redistribute ?? "",
}))
}
// ── route plugin ──────────────────────────────────────────────────────────────
const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
app.get("/ospf/neighbors", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseNeighbors(server, neighbors, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
app.get("/ospf/interfaces", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { ifaceTemplates, areas, instances } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
app.get("/ospf/instances", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { instances } = await fetchServerOspf(server)
return parseInstances(server, instances)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
app.get("/ospf/all", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return {
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
}
} catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [] }
}
}),
)
return reply.send({
neighbors: perServer.flatMap(r => r.neighbors),
interfaces: perServer.flatMap(r => r.interfaces),
instances: perServer.flatMap(r => r.instances),
bfdSessions: perServer.flatMap(r => r.bfdSessions),
})
})
// GET /api/bfd/sessions — BFD sessions only (for direct access)
app.get("/bfd/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBfdSessions()
return parseBfdSessions(server, raw)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/servers/:id/ospf — single server OSPF + BFD data
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return reply.send({
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })),
})
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
}
export default ospfRoutes
+264
View File
@@ -0,0 +1,264 @@
import { asc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosRoute {
".id"?: string
"dst-address"?: string
gateway?: string
distance?: string
scope?: string
"target-scope"?: string
"routing-table"?: string
"check-gateway"?: string
comment?: string
disabled?: string
static?: string
dynamic?: string
blackhole?: string
unreachable?: string
prohibit?: string
active?: string
}
interface GatewayOptionDto {
id: string
name: string
ip: string
status: "up" | "down"
}
interface RecursiveRouteDto {
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
country: string
comment: string
disabled: boolean
}
function parseServerId(raw: unknown): number | null {
const direct = Number.parseInt(String(raw ?? ""), 10)
if (Number.isFinite(direct)) return direct
const legacy = String(raw ?? "").match(/(\d+)/)?.[1]
if (!legacy) return null
const parsed = Number.parseInt(legacy, 10)
return Number.isFinite(parsed) ? parsed : null
}
function isIpGateway(gw: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
}
function isRecursiveRoute(r: RosRoute): boolean {
if ((r.static ?? "false") !== "true") return false
if ((r.dynamic ?? "false") === "true") return false
if ((r.blackhole ?? "false") === "true") return false
if ((r.unreachable ?? "false") === "true") return false
if ((r.prohibit ?? "false") === "true") return false
const dst = r["dst-address"] ?? ""
const gw = r.gateway ?? ""
if (!dst || !gw) return false
return isIpGateway(gw)
}
function hasRecursiveCommentMask(comment: string | undefined): boolean {
if (!comment) return false
return /^recursive:\s*/i.test(comment.trim())
}
function splitGateway(raw: string): { ip: string; name: string } | null {
const v = raw.trim()
if (!v) return null
const [ip, name] = v.split("%")
if (!ip || !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return null
return { ip, name: name || ip }
}
function mapDbRoutes(serverId: number): RecursiveRouteDto[] {
const rows = db
.select()
.from(recursiveRoutes)
.where(eq(recursiveRoutes.serverId, serverId))
.orderBy(asc(recursiveRoutes.sortOrder))
.all()
return rows.map((r) => ({
id: String(r.id),
dstAddress: r.dstAddress,
gateway: r.gateway,
distance: r.distance,
scope: r.scope ?? null,
targetScope: r.targetScope ?? null,
routingTable: r.routingTable,
checkGateway: r.checkGateway,
country: r.country ?? "",
comment: r.comment,
disabled: r.disabled,
}))
}
function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
return {
"dst-address": route.dstAddress,
gateway: route.gateway,
distance: String(route.distance),
...(route.scope != null ? { scope: String(route.scope) } : {}),
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
comment: route.comment
? `RouterLists:recursive ${route.comment}`
: "RouterLists:recursive",
disabled: route.disabled ? "true" : "false",
}
}
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId)).run()
if (routes.length === 0) return
const now = new Date().toISOString()
db.insert(recursiveRoutes).values(
routes.map((r, i) => ({
serverId,
sortOrder: i,
dstAddress: r.dstAddress,
gateway: r.gateway,
distance: r.distance,
scope: r.scope,
targetScope: r.targetScope,
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
country: r.country ?? "",
comment: r.comment ?? "",
disabled: r.disabled,
createdAt: now,
updatedAt: now,
})),
).run()
}
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const map = new Map<string, GatewayOptionDto>()
for (const r of rosRoutes) {
const g = splitGateway(r.gateway ?? "")
if (!g) continue
const id = `${g.ip}%${g.name}`
if (!map.has(id)) {
map.set(id, {
id,
name: g.name,
ip: g.ip,
status: r.active === "true" ? "up" : "down",
})
}
}
return reply.send({ gateways: [...map.values()] })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
app.get("/recursive-routes", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send({ routes: mapDbRoutes(serverId) })
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
const serverId = parseServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
await replaceDbRoutes(serverId, body.routes ?? [])
return reply.send({ ok: true })
})
app.post("/recursive-routes/sync/from-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server: ServerRow | undefined = db
.select().from(servers)
.where(eq(servers.id, serverId))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const rec = rosRoutes.filter(r =>
isRecursiveRoute(r) && hasRecursiveCommentMask(r.comment),
)
const mapped: RecursiveRouteDto[] = rec.map((r, i) => ({
id: r[".id"] ?? `ros-${i}`,
dstAddress: r["dst-address"] ?? "",
gateway: r.gateway ?? "",
distance: Number.parseInt(r.distance ?? "1", 10) || 1,
scope: r.scope ? (Number.parseInt(r.scope, 10) || null) : null,
targetScope: r["target-scope"] ? (Number.parseInt(r["target-scope"], 10) || null) : null,
routingTable: r["routing-table"] ?? "main",
checkGateway: r["check-gateway"] ?? "",
country: "",
comment: r.comment ?? "",
disabled: r.disabled === "true",
}))
await replaceDbRoutes(serverId, mapped)
return reply.send({ ok: true, serverId, totalRoutes: mapped.length })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
app.post("/recursive-routes/sync/to-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosRoute[]>("/ip/route")
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:recursive"))
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
}
const dbRows = mapDbRoutes(serverId)
for (const route of dbRows) {
await client.post("/ip/route", toRouterPayload(route))
}
return reply.send({ ok: true, serverId, pushedRoutes: dbRows.length })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
}
export default recursiveRoutesPlugin
+224
View File
@@ -0,0 +1,224 @@
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 } from "../types/server.js"
import {
ServerCreateSchema,
ServerUpdateSchema,
ServerIdParamSchema,
SnapshotsQuerySchema,
TestConnectionSchema,
} from "../types/server.js"
import { pollServer, toSnapshotRead } from "../services/poller.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
// ── helpers ────────────────────────────────────────────────────────────────────
type ServerRow = typeof servers.$inferSelect
type SnapshotRow = typeof serverSnapshots.$inferSelect
/** 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,
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 ─────────────────────────────────────────────────────────────────────
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 client = new MikrotikClient({ host, port, useSsl, verifySsl, apiPath, username, password })
try {
const t0 = performance.now()
const [identity, resource] = await Promise.all([
client.getIdentity(),
client.getResource(),
])
const latencyMs = Math.round(performance.now() - t0)
return reply.send({
success: true,
latencyMs,
identity: identity.name,
version: resource["version"],
boardName: resource["board-name"],
uptime: resource["uptime"],
message: `Подключено · RouterOS ${resource["version"]} · ${resource["board-name"]}`,
})
} catch (err) {
let message = "Не удалось подключиться"
if (err instanceof MikrotikError) {
if (err.statusCode === 401) message = "Неверный логин или пароль (401 Unauthorized)"
else if (err.statusCode === 403) message = "Нет прав доступа к REST API (403 Forbidden)"
else message = `Ошибка RouterOS API: ${err.statusCode}`
} else if (err instanceof Error) {
const msg = err.message.toLowerCase()
if (msg.includes("timeout") || msg.includes("abort"))
message = `Нет ответа от ${host}:${port} — проверьте IP и что сервис www/www-ssl включён`
else if (msg.includes("econnrefused"))
message = `Соединение отклонено — порт ${port} закрыт`
else if (msg.includes("cert") || msg.includes("ssl") || msg.includes("tls"))
message = "Ошибка SSL-сертификата — отключите «Проверять SSL» для self-signed"
else if (msg.includes("enotfound") || msg.includes("getaddrinfo"))
message = `Хост не найден: ${host}`
else
message = err.message
}
return reply.status(502).send({ success: false, message })
}
})
// 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)
})
// POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
const now = new Date().toISOString()
const [inserted] = db
.insert(servers)
.values({ ...req.body, createdAt: now, updatedAt: now })
.returning()
.all()
return reply.status(201).send(toServerRead(inserted, undefined))
})
// 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]
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send(toServerRead(server, getLatestSnapshot(server.id)))
})
// PUT /api/servers/:id
app.put(
"/: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]
if (!existing) return reply.status(404).send({ error: "Server not found" })
const [updated] = db
.update(servers)
.set({ ...req.body, updatedAt: new Date().toISOString() })
.where(eq(servers.id, req.params.id))
.returning()
.all()
return reply.send(toServerRead(updated, getLatestSnapshot(updated.id)))
},
)
// 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]
if (!existing) return reply.status(404).send({ error: "Server not found" })
db.delete(servers).where(eq(servers.id, req.params.id)).run()
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]
if (!existing) return reply.status(404).send({ error: "Server not found" })
try {
const snap = await pollServer(req.params.id)
return reply.send(snap)
} catch (err) {
return reply.status(500).send({ error: (err as Error).message })
}
})
// GET /api/servers/:id/snapshots
app.get(
"/: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]
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))
},
)
}
export default serversRoutes
+285
View File
@@ -0,0 +1,285 @@
import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { serverSnapshots, servers } from "../db/schema.js"
import {
collectTrafficOnce,
getTrafficCollectorState,
getTrafficSettings,
readServerSamplesInRange,
updateTrafficSettings,
} from "../services/traffic-collector.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
interface TrafficServerDto {
id: string
name: string
site: string
country: string
status: "online" | "offline" | "degraded"
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
sessions: number
rxSeries: number[]
txSeries: number[]
}
interface TrafficInterfaceDto {
name: string
running: boolean
disabled: boolean
rxNow: number
txNow: number
}
function latestSnapshot(serverId: number): SnapshotRow | undefined {
return db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
.all()[0]
}
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "1h").toLowerCase()) {
case "5m": return 5
case "15m": return 15
case "1h": return 60
case "4h": return 240
case "24h": return 1440
default: return 60
}
}
function toSeries(values: number[], target = 60): number[] {
if (values.length === 0) return Array(target).fill(0)
if (values.length === target) return values
if (values.length > target) return values.slice(values.length - target)
const head = Array(target - values.length).fill(values[0] ?? 0)
return [...head, ...values]
}
function buildServerTraffic(
s: typeof servers.$inferSelect,
status: TrafficServerDto["status"],
rows: Array<{
interfaceName: string
sampledAt: string
rxBps: number
txBps: number
rxBytes: number
txBytes: number
running: boolean
disabled: boolean
}>,
onlyInterface?: string,
): TrafficServerDto {
const filteredRows = onlyInterface
? rows.filter((r) => r.interfaceName === onlyInterface)
: rows
if (filteredRows.length === 0) {
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotal: 0,
txTotal: 0,
sessions: 0,
rxSeries: Array(60).fill(0),
txSeries: Array(60).fill(0),
}
}
const bySampleTs = new Map<string, { rx: number; tx: number }>()
const byIface = new Map<string, typeof filteredRows>()
for (const r of filteredRows) {
const ts = r.sampledAt
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
cur.rx += Math.max(0, r.rxBps) / 1_000_000
cur.tx += Math.max(0, r.txBps) / 1_000_000
bySampleTs.set(ts, cur)
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
}
const seriesPoints = [...bySampleTs.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
const txNow = txSeries[txSeries.length - 1] ?? 0
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
let rxBytesDelta = 0
let txBytesDelta = 0
let sessions = 0
for (const arr of byIface.values()) {
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const first = sorted[0]
const last = sorted[sorted.length - 1]
if (first && last) {
const dRx = last.rxBytes - first.rxBytes
const dTx = last.txBytes - first.txBytes
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
if (last.running && !last.disabled) sessions += 1
}
}
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
rxNow,
txNow,
rxPeak,
txPeak,
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
sessions,
rxSeries,
txSeries,
}
}
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/settings", async (_req, reply) => {
const settings = getTrafficSettings()
const state = getTrafficCollectorState()
return reply.send({
enabled: settings.enabled,
intervalSec: settings.intervalSec,
retentionDays: settings.retentionDays,
lastCollectedAt: settings.lastCollectedAt ?? null,
lastDurationMs: settings.lastDurationMs ?? null,
lastError: settings.lastError || null,
collectorRunning: state.running,
})
})
app.put("/traffic/settings", async (req, reply) => {
const body = req.body as {
enabled?: boolean
intervalSec?: number | string
retentionDays?: number | string
}
const intervalSec = body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 30)
const retentionDays = body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14)
const updated = updateTrafficSettings({
enabled: body.enabled,
intervalSec,
retentionDays,
})
return reply.send({
ok: true,
settings: {
enabled: updated.enabled,
intervalSec: updated.intervalSec,
retentionDays: updated.retentionDays,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
},
})
})
app.post("/traffic/collect-now", async (_req, reply) => {
await collectTrafficOnce()
const updated = getTrafficSettings()
return reply.send({
ok: true,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
})
})
app.get("/traffic/servers", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const data = allServers.map((s): TrafficServerDto => {
const snap = latestSnapshot(s.id)
const status: TrafficServerDto["status"] =
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
const rows = readServerSamplesInRange(s.id, sinceIso)
return buildServerTraffic(s, status, rows)
})
return reply.send({ servers: data })
})
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
const p = req.params as { id?: string | number }
const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const allRows = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()
const server = allRows[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const rows = readServerSamplesInRange(serverId, sinceIso)
const byIface = new Map<string, typeof rows>()
for (const r of rows) {
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
}
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
return {
name,
running: Boolean(last?.running),
disabled: Boolean(last?.disabled),
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
}
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
return reply.send({ interfaces })
})
app.get("/traffic/servers/:id", async (req, reply) => {
const p = req.params as { id?: string | number }
const q = req.query as { range?: string; iface?: string }
const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const rows = readServerSamplesInRange(server.id, sinceIso)
const snap = latestSnapshot(server.id)
const status: TrafficServerDto["status"] =
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
return reply.send({ server: data })
})
}
export default trafficRoutes
+317
View File
@@ -0,0 +1,317 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import {
collectUptimeOnce,
readProbeRows,
readProbeSamplesSince,
readResourceSamplesSince,
readUptimeSettings,
replaceProbes,
updateUptimeSettings,
} from "../services/uptime-collector.js"
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "1h").toLowerCase()) {
case "5m": return 5
case "15m": return 15
case "1h": return 60
case "4h": return 240
case "24h": return 1440
default: return 60
}
}
function toSeries(values: number[], target = 40): number[] {
if (values.length === 0) return Array(target).fill(0)
if (values.length === target) return values
if (values.length > target) return values.slice(values.length - target)
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
}
function parseRateToMbps(raw: unknown): number {
const txt = String(raw ?? "").trim().toLowerCase()
if (!txt) return 0
const m = txt.match(/^(\d+(?:\.\d+)?)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/)
if (m) {
const n = Number.parseFloat(m[1])
const u = m[2] ?? ""
if (!Number.isFinite(n)) return 0
if (u === "g") return n * 1000
if (u === "m") return n
if (u === "k") return n / 1000
return n / 1_000_000
}
const n = Number.parseFloat(txt.replace(/[^\d.]/g, ""))
if (!Number.isFinite(n)) return 0
return n > 10000 ? n / 1_000_000 : n
}
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/settings", async (_req, reply) => {
const s = readUptimeSettings()
return reply.send({
enabled: s.enabled,
intervalSec: s.intervalSec,
retentionDays: s.retentionDays,
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
lastError: s.lastError || null,
})
})
app.put("/uptime/settings", async (req, reply) => {
const body = req.body as { enabled?: boolean; intervalSec?: number | string; retentionDays?: number | string }
const updated = updateUptimeSettings({
enabled: body.enabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
})
return reply.send({
ok: true,
settings: {
enabled: updated.enabled,
intervalSec: updated.intervalSec,
retentionDays: updated.retentionDays,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
},
})
})
app.post("/uptime/collect-now", async (_req, reply) => {
await collectUptimeOnce()
const s = readUptimeSettings()
return reply.send({
ok: true,
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
lastError: s.lastError || null,
})
})
app.get("/uptime/probes", async (_req, reply) => {
return reply.send({ probes: readProbeRows() })
})
app.put("/uptime/probes", async (req, reply) => {
const body = req.body as {
probes?: Array<{
id?: string
srcServerId?: string | number
srcInterface?: string
name?: string
target?: string
filter?: string
enabled?: boolean
}>
}
const normalized = (body.probes ?? [])
.map((p, i) => ({
id: p.id || `p-${Date.now()}-${i}`,
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
srcInterface: String(p.srcInterface ?? "").trim(),
name: String(p.name ?? "").trim(),
target: String(p.target ?? "").trim(),
probeFilter: String(p.filter ?? "—"),
enabled: p.enabled !== false,
}))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
replaceProbes(normalized)
return reply.send({ ok: true })
})
app.get("/uptime/sources/:id/interfaces", async (req, reply) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!srv) return reply.status(404).send({ error: "Server not found" })
try {
const rows = await MikrotikClient.fromServer(srv).getInterfaces()
const interfaces = rows
.map((r) => ({
name: String(r.name ?? "").trim(),
running: String(r.running ?? "false").toLowerCase() === "true",
disabled: String(r.disabled ?? "false").toLowerCase() === "true",
}))
.filter((r) => r.name.length > 0)
.sort((a, b) => a.name.localeCompare(b.name))
return reply.send({ interfaces })
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to fetch interfaces"
return reply.status(502).send({ error: message })
}
})
app.post("/uptime/speed-test", async (req, reply) => {
const body = req.body as {
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
}
const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10)
const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10)
if (!Number.isFinite(srcId) || !Number.isFinite(dstId)) {
return reply.status(400).send({ error: "Invalid src/dst server id" })
}
if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" })
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0]
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
try {
const protocol = body.protocol === "udp" ? "udp" : "tcp"
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
const srcInterface = String(body.srcInterface ?? "").trim()
const dstInterface = String(body.dstInterface ?? "").trim()
const srcClient = MikrotikClient.fromServer(src)
const dstAddressRows = dstInterface
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
: []
const dstAddress = dstInterface
? String(
dstAddressRows.find((a) => String(a.interface ?? "") === dstInterface && String(a.address ?? "").includes("/"))?.address ??
"",
).split("/")[0]
: dst.host
if (dstInterface && !dstAddress) {
return reply.status(400).send({ error: `На интерфейсе назначения '${dstInterface}' нет IP-адреса` })
}
const initialAddress = dstAddress || dst.host
const rows = await srcClient.bandwidthTest({
address: initialAddress,
user: dst.username,
password: dst.password,
protocol,
direction,
durationSec,
})
const txSeries = rows
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const rxSeries = rows
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const last = rows[rows.length - 1] ?? {}
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
return reply.send({
ok: true,
result: {
srcInterface: srcInterface || null,
dstInterface: dstInterface || null,
address: initialAddress,
protocol,
direction,
durationSec,
txAvgMbps: txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0),
rxAvgMbps: rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0),
txSeries,
rxSeries,
raw: rows,
},
})
} catch (e) {
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
: raw
return reply.status(502).send({ error: message })
}
})
app.get("/uptime/speed-test/endpoints/:id/interfaces", async (req, reply) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!srv) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(srv)
const [ifaces, ips] = await Promise.all([
client.getInterfaces(),
client.getIpAddresses(),
])
const interfaces = ifaces
.map((i) => {
const name = String(i.name ?? "").trim()
const addresses = ips
.filter((a) => String(a.interface ?? "") === name)
.map((a) => String(a.address ?? "").split("/")[0])
.filter((x) => x.length > 0)
return {
name,
running: String(i.running ?? "false").toLowerCase() === "true",
disabled: String(i.disabled ?? "false").toLowerCase() === "true",
addresses,
}
})
.filter((i) => i.name.length > 0)
return reply.send({ interfaces })
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to fetch speed-test interfaces"
return reply.status(502).send({ error: message })
}
})
app.get("/uptime/overview", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const allServers = db.select().from(servers).all()
const probeRows = readProbeRows()
const probeSamples = readProbeSamplesSince(sinceIso)
const probes = probeRows.map((p) => {
const rows = probeSamples.filter((r) => r.probeId === p.id)
const last = rows[rows.length - 1]
const series = toSeries(rows.map((r) => r.rttMs ?? 0))
return {
id: p.id,
srcServerId: String(p.srcServerId),
srcInterface: p.srcInterface || "",
name: p.name,
target: p.target,
filter: p.probeFilter || "—",
rtt: last?.rttMs ?? null,
loss: last?.lossPct ?? 100,
status: (last?.status ?? "down") as "up" | "warn" | "down",
series,
enabled: p.enabled,
}
})
const resources = allServers.map((s) => {
const rows = readResourceSamplesSince(sinceIso, s.id)
const last = rows[rows.length - 1]
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
return {
serverId: String(s.id),
cpu: last?.cpuLoad ?? 0,
cpuHistory,
ramUsed: Math.max(0, ((last?.totalMemory ?? 0) - (last?.freeMemory ?? 0)) / (1024 * 1024)),
ramTotal: Math.max(0, (last?.totalMemory ?? 0) / (1024 * 1024)),
hddUsed: Math.max(0, ((last?.totalHddSpace ?? 0) - (last?.freeHddSpace ?? 0)) / (1024 * 1024)),
hddTotal: Math.max(0, (last?.totalHddSpace ?? 0) / (1024 * 1024)),
uptimeSeconds: last?.uptimeSeconds ?? 0,
boardName: last?.boardName || "RouterBOARD",
temp: undefined as number | undefined,
}
})
return reply.send({ probes, resources })
})
}
export default uptimeRoutes
+304
View File
@@ -0,0 +1,304 @@
import http from "node:http"
import https from "node:https"
import type { Server } from "../db/schema.js"
import type {
RosIdentity, RosInterface, RosIpAddress, RosResource,
RosBgpSession,
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
} from "../types/server.js"
// ── connection params ─────────────────────────────────────────────────────────
export interface MikrotikConnectParams {
host: string
port: number
useSsl: boolean
verifySsl: boolean
username: string
password: string
apiPath?: string // defaults to "/rest"
}
// ── low-level HTTP helpers ────────────────────────────────────────────────────
function rosRequest(
params: MikrotikConnectParams,
path: 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 options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "GET",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
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 body = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, body))
return
}
try {
resolve(JSON.parse(body))
} catch {
reject(new Error(`Invalid JSON from RouterOS: ${body.slice(0, 200)}`))
}
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
function rosPost(
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: "POST",
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
}
try { resolve(JSON.parse(buf)) } catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
})
})
req.on("error", (err) => { clearTimeout(timer); reject(err) })
req.write(payload)
req.end()
})
}
function rosDelete(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "DELETE",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
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 body = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, body)); return
}
resolve()
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
constructor(private readonly params: MikrotikConnectParams) {}
/** Convenience factory from a DB Server row */
static fromServer(server: Server): MikrotikClient {
return new MikrotikClient({
host: server.host,
port: server.port,
useSsl: server.useSsl,
verifySsl: server.verifySsl,
username: server.username,
password: server.password,
})
}
async get<T>(path: string, timeoutMs = 10_000): Promise<T> {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPost(this.params, path, body, timeoutMs) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
return rosDelete(this.params, path, timeoutMs)
}
// ── typed helpers ──────────────────────────────────────────────────────────
async getIdentity(): Promise<RosIdentity> {
return this.get<RosIdentity>("/system/identity")
}
async getResource(): Promise<RosResource> {
return this.get<RosResource>("/system/resource")
}
async getInterfaces(): Promise<RosInterface[]> {
return this.get<RosInterface[]>("/interface")
}
async getIpAddresses(): Promise<RosIpAddress[]> {
return this.get<RosIpAddress[]>("/ip/address")
}
async getBgpSessions(): Promise<RosBgpSession[]> {
return this.get<RosBgpSession[]>("/routing/bgp/session")
}
/** Returns the raw (un-typed) BGP session objects — used for debugging */
async getBgpSessionsRaw(): Promise<unknown[]> {
return this.get<unknown[]>("/routing/bgp/session")
}
// ── OSPF ──────────────────────────────────────────────────────────────────
async getOspfNeighbors(): Promise<RosOspfNeighbor[]> {
return this.get<RosOspfNeighbor[]>("/routing/ospf/neighbor")
}
async getOspfAreas(): Promise<RosOspfArea[]> {
return this.get<RosOspfArea[]>("/routing/ospf/area")
}
async getOspfInterfaceTemplates(): Promise<RosOspfInterfaceTemplate[]> {
return this.get<RosOspfInterfaceTemplate[]>("/routing/ospf/interface-template")
}
async getOspfInstances(): Promise<RosOspfInstance[]> {
return this.get<RosOspfInstance[]>("/routing/ospf/instance")
}
async getBfdSessions(): Promise<RosBfdSession[]> {
return this.get<RosBfdSession[]>("/routing/bfd/session")
}
// ── extra endpoints for exec route ────────────────────────────────────────
async getIpRoutes(): Promise<RosIpRoute[]> {
return this.get<RosIpRoute[]>("/ip/route")
}
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
}
async getLogs(limit = 50): Promise<RosLogEntry[]> {
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
}
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
const body: Record<string, string> = {
address,
count: String(count),
interval: "0.2s",
}
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
}
async bandwidthTest(params: {
address: string
user: string
password: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: number
}): Promise<Array<Record<string, string>>> {
const body: Record<string, string> = {
address: params.address,
user: params.user,
password: params.password,
protocol: params.protocol ?? "tcp",
direction: params.direction ?? "both",
duration: `${Math.max(3, params.durationSec ?? 10)}s`,
}
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
}
}
// ── Error type ─────────────────────────────────────────────────────────────────
export class MikrotikError extends Error {
constructor(
public readonly statusCode: number,
public readonly path: string,
public readonly body: string,
) {
super(`RouterOS API error ${statusCode} on ${path}: ${body}`)
this.name = "MikrotikError"
}
}
+104
View File
@@ -0,0 +1,104 @@
import { desc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { SnapshotInsert } from "../db/schema.js"
import type { SnapshotRead } from "../types/server.js"
import { MikrotikClient } from "./mikrotik.js"
// ── pollServer ─────────────────────────────────────────────────────────────────
/**
* Connect to a RouterOS device, collect system info, persist a snapshot,
* and sync the server's display name from system/identity.
*/
export async function pollServer(serverId: number): Promise<SnapshotRead> {
const server = db
.select()
.from(servers)
.where(eq(servers.id, serverId))
.limit(1)
.all()[0]
if (!server) {
throw new Error(`Server with id=${serverId} not found`)
}
const now = new Date().toISOString()
const client = MikrotikClient.fromServer(server)
const t0 = performance.now()
const partialSnap: Partial<SnapshotInsert> = {
serverId,
polledAt: now,
status: "offline",
latencyMs: null,
}
try {
// Fire all requests in parallel for speed
const [identity, resource, ifaces, addresses] = await Promise.all([
client.getIdentity(),
client.getResource(),
client.getInterfaces(),
client.getIpAddresses(),
])
const latencyMs = performance.now() - t0
// Parse RouterOS string values (RouterOS REST API returns everything as strings)
const cpuLoad = parseInt(resource["cpu-load"], 10)
const freeMem = parseInt(resource["free-memory"], 10)
const totalMem = parseInt(resource["total-memory"], 10)
Object.assign(partialSnap, {
status: "online",
latencyMs,
identityName: identity.name,
rosVersion: resource["version"],
boardName: resource["board-name"],
uptime: resource["uptime"],
cpuLoad: isNaN(cpuLoad) ? null : cpuLoad,
freeMemory: isNaN(freeMem) ? null : freeMem,
totalMemory: isNaN(totalMem) ? null : totalMem,
rawInterfaces: JSON.stringify(ifaces),
rawIpAddresses: JSON.stringify(addresses),
} satisfies Partial<SnapshotInsert>)
// Keep server.name in sync with RouterOS identity
db.update(servers)
.set({ name: identity.name, updatedAt: now })
.where(eq(servers.id, serverId))
.run()
} catch (err) {
// Log but don't throw — we still persist the offline snapshot
console.warn(`[poller] server id=${serverId} unreachable:`, (err as Error).message)
}
const [inserted] = db
.insert(serverSnapshots)
.values(partialSnap as SnapshotInsert)
.returning()
.all()
return toSnapshotRead(inserted)
}
// ── helper ─────────────────────────────────────────────────────────────────────
export function toSnapshotRead(s: typeof serverSnapshots.$inferSelect): SnapshotRead {
return {
id: s.id,
serverId: s.serverId,
polledAt: s.polledAt,
status: s.status,
latencyMs: s.latencyMs ?? null,
rosVersion: s.rosVersion ?? null,
boardName: s.boardName ?? null,
uptime: s.uptime ?? null,
cpuLoad: s.cpuLoad ?? null,
freeMemory: s.freeMemory ?? null,
totalMemory: s.totalMemory ?? null,
identityName: s.identityName ?? null,
}
}
+169
View File
@@ -0,0 +1,169 @@
import { and, asc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
interface RosIfaceTraffic {
name?: string
running?: string
disabled?: string
"rx-byte"?: string
"tx-byte"?: string
"rx-bits-per-second"?: string
"tx-bits-per-second"?: string
}
export interface TrafficCollectorState {
running: boolean
lastRunAt: string | null
lastError: string | null
}
let timer: ReturnType<typeof setInterval> | null = null
let collecting = false
const state: TrafficCollectorState = {
running: false,
lastRunAt: null,
lastError: null,
}
function toNum(raw: unknown): number {
const n = Number.parseFloat(String(raw ?? "0"))
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0
}
function getSettingsRow() {
const row = db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = new Date().toISOString()
db.insert(trafficSettings).values({
id: 1,
enabled: true,
intervalSec: 30,
retentionDays: 14,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
}
function cleanupOldSamples(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
db.delete(trafficSamples)
.where(lt(trafficSamples.sampledAt, cutoff))
.run()
}
export async function collectTrafficOnce(): Promise<void> {
if (collecting) return
collecting = true
const startedAt = Date.now()
const now = new Date().toISOString()
const settings = getSettingsRow()
try {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
for (const srv of enabledServers) {
try {
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
if (ifaces.length === 0) continue
db.insert(trafficSamples).values(
ifaces.map((i) => ({
serverId: srv.id,
interfaceName: i.name ?? "unknown",
sampledAt: now,
rxBytes: toNum(i["rx-byte"]),
txBytes: toNum(i["tx-byte"]),
rxBps: toNum(i["rx-bits-per-second"]),
txBps: toNum(i["tx-bits-per-second"]),
running: (i.running ?? "false") === "true",
disabled: (i.disabled ?? "false") === "true",
})),
).run()
} catch {
// Continue collecting from remaining servers
}
}
cleanupOldSamples(Math.max(1, settings.retentionDays))
db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: "",
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
state.lastRunAt = now
state.lastError = null
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: msg,
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
state.lastRunAt = now
state.lastError = msg
} finally {
collecting = false
}
}
export function stopTrafficCollector() {
if (timer) {
clearInterval(timer)
timer = null
}
state.running = false
}
export function restartTrafficCollector() {
stopTrafficCollector()
const settings = getSettingsRow()
if (!settings.enabled) return
const intervalMs = Math.max(5, settings.intervalSec) * 1000
timer = setInterval(() => {
void collectTrafficOnce()
}, intervalMs)
state.running = true
}
export function getTrafficCollectorState(): TrafficCollectorState {
return { ...state }
}
export function getTrafficSettings() {
return getSettingsRow()
}
export function updateTrafficSettings(patch: {
enabled?: boolean
intervalSec?: number
retentionDays?: number
}) {
const prev = getSettingsRow()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run()
restartTrafficCollector()
return getSettingsRow()
}
export function readServerSamplesInRange(serverId: number, sinceIso: string) {
return db.select()
.from(trafficSamples)
.where(and(
eq(trafficSamples.serverId, serverId),
gte(trafficSamples.sampledAt, sinceIso),
))
.orderBy(asc(trafficSamples.sampledAt))
.all()
}
+267
View File
@@ -0,0 +1,267 @@
import { and, asc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import {
servers,
uptimeProbeSamples,
uptimeProbes,
uptimeResourceSamples,
uptimeSettings,
} from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
let timer: ReturnType<typeof setInterval> | null = null
let collecting = false
function parseDurationToSeconds(raw: string | undefined): number {
if (!raw) return 0
let total = 0
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
const n = Number.parseInt(m[1], 10)
if (!Number.isFinite(n)) continue
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
}
}
return total
}
function toNum(raw: unknown): number {
const n = Number.parseInt(String(raw ?? "0"), 10)
return Number.isFinite(n) ? Math.max(0, n) : 0
}
function parsePingTimeMs(raw: string | undefined): number | null {
if (!raw) return null
const s = String(raw).trim().toLowerCase().replace(",", ".")
const us = s.match(/^(\d+(?:\.\d+)?)\s*us$/)
if (us) return Number.parseFloat(us[1]) / 1000
const ms = s.match(/^(\d+(?:\.\d+)?)\s*ms$/)
if (ms) return Number.parseFloat(ms[1])
const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/)
if (sec) return Number.parseFloat(sec[1]) * 1000
// HH:MM:SS(.sss) from some RouterOS outputs
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
if (clock) {
const h = Number.parseFloat(clock[1])
const m = Number.parseFloat(clock[2])
const sc = Number.parseFloat(clock[3])
return (h * 3600 + m * 60 + sc) * 1000
}
const n = Number.parseFloat(s)
return Number.isFinite(n) ? n : null
}
function getSettings() {
const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = new Date().toISOString()
db.insert(uptimeSettings).values({
id: 1,
enabled: true,
intervalSec: 15,
retentionDays: 14,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
}
function cleanup(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
db.delete(uptimeProbeSamples).where(lt(uptimeProbeSamples.sampledAt, cutoff)).run()
db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff)).run()
}
function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string }>) {
const sum = [...results].reverse().find((r) => r.sent || r.received || r["packet-loss"])
const lossRaw = sum?.["packet-loss"] ?? "100%"
const lossPct = Number.parseInt(String(lossRaw).replace("%", ""), 10)
const okReplies = results.filter((r) => r.time && r.status !== "timeout")
const rtts = okReplies
.map((r) => parsePingTimeMs(r.time))
.filter((v): v is number => v != null && Number.isFinite(v))
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
const avgFromSummary = parsePingTimeMs(sum?.time)
const avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
const loss = Number.isFinite(lossPct) ? Math.max(0, Math.min(100, lossPct)) : 100
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
return { avgRtt, loss, status }
}
export async function collectUptimeOnce(): Promise<void> {
if (collecting) return
collecting = true
const started = Date.now()
const now = new Date().toISOString()
const settings = getSettings()
try {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
for (const s of enabledServers) {
try {
const client = MikrotikClient.fromServer(s)
const resource = await client.getResource()
db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
status: "online",
cpuLoad: toNum(resource["cpu-load"]),
freeMemory: toNum(resource["free-memory"]),
totalMemory: toNum(resource["total-memory"]),
freeHddSpace: toNum(resource["free-hdd-space"]),
totalHddSpace: toNum(resource["total-hdd-space"]),
uptimeSeconds: parseDurationToSeconds(resource["uptime"]),
boardName: String(resource["board-name"] ?? ""),
rosVersion: String(resource["version"] ?? ""),
}).run()
} catch {
db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
status: "offline",
cpuLoad: 0,
freeMemory: 0,
totalMemory: 0,
freeHddSpace: 0,
totalHddSpace: 0,
uptimeSeconds: 0,
boardName: "",
rosVersion: "",
}).run()
}
}
const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all()
for (const p of probes) {
const src = enabledServers.find((s) => s.id === p.srcServerId)
if (!src) continue
try {
const client = MikrotikClient.fromServer(src)
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
const parsed = parsePing(results)
db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: parsed.avgRtt,
lossPct: parsed.loss,
status: parsed.status,
}).run()
} catch {
db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: null,
lossPct: 100,
status: "down",
}).run()
}
}
cleanup(Math.max(1, settings.retentionDays))
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} catch (e) {
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: e instanceof Error ? e.message : String(e),
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} finally {
collecting = false
}
}
export function restartUptimeCollector() {
const s = getSettings()
if (timer) clearInterval(timer)
timer = null
if (!s.enabled) return
const intervalMs = Math.max(5, s.intervalSec) * 1000
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
}
export function stopUptimeCollector() {
if (timer) clearInterval(timer)
timer = null
}
export function readUptimeSettings() {
return getSettings()
}
export function updateUptimeSettings(patch: {
enabled?: boolean
intervalSec?: number
retentionDays?: number
}) {
const prev = getSettings()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
restartUptimeCollector()
return readUptimeSettings()
}
export function readProbeRows() {
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
}
export function replaceProbes(rows: Array<{
id: string
srcServerId: number
srcInterface: string
name: string
target: string
probeFilter: string
enabled: boolean
}>) {
db.delete(uptimeProbes).run()
if (rows.length === 0) return
const now = new Date().toISOString()
db.insert(uptimeProbes).values(rows.map((r, i) => ({
id: r.id,
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
name: r.name,
target: r.target,
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
sortOrder: i,
createdAt: now,
updatedAt: now,
}))).run()
}
export function readProbeSamplesSince(sinceIso: string) {
return db.select().from(uptimeProbeSamples)
.where(gte(uptimeProbeSamples.sampledAt, sinceIso))
.orderBy(asc(uptimeProbeSamples.sampledAt))
.all()
}
export function readResourceSamplesSince(sinceIso: string, serverId: number) {
return db.select().from(uptimeResourceSamples)
.where(and(
eq(uptimeResourceSamples.serverId, serverId),
gte(uptimeResourceSamples.sampledAt, sinceIso),
))
.orderBy(asc(uptimeResourceSamples.sampledAt))
.all()
}
+465
View File
@@ -0,0 +1,465 @@
import { z } from "zod"
// ── RouterOS raw response types ────────────────────────────────────────────────
export interface RosIdentity {
name: string
}
export interface RosResource {
"uptime": string
"version": string
"build-time": string
"factory-software": string
"free-memory": string
"total-memory": string
"cpu": string
"cpu-count": string
"cpu-frequency": string
"cpu-load": string
"free-hdd-space": string
"total-hdd-space": string
"architecture-name": string
"board-name": string
"platform": string
}
export interface RosInterface {
".id": string
name: string
type: string
mtu?: string
"actual-mtu"?: string
"mac-address"?: string
running: string // "true" | "false" (RouterOS returns strings)
disabled: string
comment?: string
}
export interface RosIpAddress {
".id": string
address: string // "192.168.1.1/24"
network: string // "192.168.1.0"
interface: string
disabled: string
dynamic?: string
invalid?: string
comment?: string
}
// ── Zod schemas for API validation ────────────────────────────────────────────
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),
})
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) ─────────────────────────────────────
/** Flat server response — mirrors the frontend's Server interface from lib/data.ts */
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
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 ────────────────────────────────────────────────────────────────
/**
* Raw BGP session object returned by RouterOS 7.x REST API
* (/rest/routing/bgp/session).
*
* NOTE: RouterOS 7.x does NOT have a "state" field.
* - "established": "true" → session is Established
* - Field absence → session is not established (Active/Idle/Connect…)
* - "ebgp": "" → session role is eBGP
* Capabilities are comma-separated short codes in "local.capabilities" /
* "remote.capabilities" (e.g. "mp,rr,gr,as4,enhe,role").
*/
export interface RosBgpSession {
".id": string
"name"?: string
// RouterOS 7.x state flags (no "state" field)
"established"?: string // "true" when session is up
"ebgp"?: string // "" when eBGP
// Addressing
"remote.address"?: string // "10.0.0.1" or "10.0.0.1/32"
"remote.as"?: string
"remote.id"?: string
"remote.messages"?: string // total received messages
"remote.hold-time"?: string // e.g. "1m30s"
"remote.capabilities"?: string // "mp,rr,gr,as4"
"remote.bytes"?: string
"remote.eor"?: string
"remote.afi"?: string
"remote.gr-time"?: string
"local.address"?: string
"local.as"?: string
"local.id"?: string
"local.messages"?: string // total sent messages
"local.capabilities"?: string // "mp,rr,enhe,role,gr,as4"
"local.bytes"?: string
"local.eor"?: string
"local.afi"?: string
"local.role"?: string // "ebgp-customer" | "ebgp-provider" | "ibgp" …
"local.cluster-id"?: string
// Timers
"hold-time"?: string // "1m30s" (active hold time, RouterOS 7.x)
"keepalive-time"?: string // "30s"
// Routing
"prefix-count"?: string
"uptime"?: string // "1d5h13m23s580ms"
"instance"?: string
"multihop"?: string // "true" | "false"
// Filters
"input.filter"?: string
"output.filter-chain"?: string
"input.procid"?: string
"output.procid"?: string
// Timestamps
"last-started"?: string
"last-notification"?: string
// Old RouterOS 6.x / some 7.x fields (kept for compatibility)
"state"?: string
"active-holdtime"?: string
"total-messages-sent"?: string
"total-messages-received"?: string
"total-updates-sent"?: string
"total-updates-received"?: string
"4-octet-as-capability"?: string
"refresh-capability"?: string
"as4-capability"?: string
"add-path-capability"?: string
"graceful-restart-capability"?: string
"extended-message-capability"?: string
}
export interface BgpSessionRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
name: string
peerIp: string
remoteAs: number
localAs: number
localId: string
remoteId: string
state: string
type: "eBGP" | "iBGP"
uptime: string | null
holdTime: number
keepalive: number
prefixesRx: number
prefixesTx: number
inputMessages: number
outputMessages: number
capabilities: string[]
lastError: string | null
}
// ── OSPF types ────────────────────────────────────────────────────────────────
/** Raw RouterOS 7.x /rest/routing/ospf/neighbor */
export interface RosOspfNeighbor {
".id": string
"address": string // neighbor IP
"router-id": string
"instance": string // OSPF instance name
"area": string // area NAME (not area-id)
"interface": string // local interface
"state": string // "Full" | "2-Way" | "ExStart" | "Exchange" | "Loading" | "Down"
"adjacency"?: string // uptime of adjacency, e.g. "1h30m17s"
"state-changes"?: string
"timeout"?: string
"dynamic"?: string
"priority"?: string
}
/** Raw RouterOS 7.x /rest/routing/ospf/area */
export interface RosOspfArea {
".id": string
"name": string
"area-id"?: string // "0.0.0.1" — may be absent for backbone when using ROS defaults
"instance": string
"type": string // "default" | "stub" | "nssa" | "totally-stub"
"disabled": string
"inactive": string
".about"?: string // error note when area is inactive
"nssa-translator"?: string
}
/** Raw RouterOS 7.x /rest/routing/ospf/interface-template */
export interface RosOspfInterfaceTemplate {
".id": string
"area": string // area NAME (not area-id)
"interfaces"?: string // interface name or "*ID" ref
"cost"?: string
"priority"?: string
"type"?: string // "ptp" | "broadcast" | "nbma" | "ptmp" | "ptmp-broadcast"
"disabled": string
"inactive": string
"hello-interval"?: string // e.g. "10s"
"dead-interval"?: string // e.g. "40s"
"retransmit-interval"?: string
"transmit-delay"?: string
"use-bfd"?: string
"instance-id"?: string
".about"?: string
}
/** Raw RouterOS 7.x /rest/routing/ospf/instance */
export interface RosOspfInstance {
".id": string
"name": string
"router-id": string // may be "main" to use main routing table router-id
"version": string // "2" | "3"
"disabled": string
"inactive": string
"redistribute"?: string
"routing-table"?: string
"vrf"?: string
"in-filter-chain"?: string
"out-filter-chain"?: string
}
// ── Normalized OSPF read types (API response) ─────────────────────────────────
export interface OspfNeighborRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
address: string
routerId: string
instance: string
area: string // area name
areaId: string // area-id like "0.0.0.1"
interface: string
state: string
uptime: string | null
stateChanges: number
priority: number
}
export interface OspfInterfaceRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
instance: string
area: string // area name
areaId: string // area-id
interface: string // interface name
cost: number
type: string // "ptp" | "broadcast" | ...
disabled: boolean
inactive: boolean
priority: number
helloInterval: number // seconds
deadInterval: number // seconds
useBfd: boolean
}
export interface OspfInstanceRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
name: string
routerId: string
version: number
disabled: boolean
inactive: boolean
redistribute: string
}
// ── IP Route / Firewall / Log / Ping ─────────────────────────────────────────
export interface RosIpRoute {
".id": string
"dst-address": string
"pref-src"?: string
"gateway"?: string
"distance"?: string
"scope"?: string
"active"?: string // "true"
"dynamic"?: string
"static"?: string
"connect"?: string
"bgp"?: string
"ospf"?: string
"rip"?: string
"blackhole"?: string
"unreachable"?: string
"prohibit"?: string
"interface"?: string
"routing-mark"?: string
"type"?: string
}
export interface RosFirewallFilter {
".id": string
"chain": string
"action": string
"protocol"?: string
"src-address"?: string
"dst-address"?: string
"src-port"?: string
"dst-port"?: string
"in-interface"?: string
"out-interface"?: string
"connection-state"?: string
"src-address-list"?: string
"dst-address-list"?: string
"layer7-protocol"?: string
"tls-host"?: string
"comment"?: string
"disabled"?: string
"invalid"?: string
"dynamic"?: string
"bytes"?: string
"packets"?: string
}
export interface RosLogEntry {
".id": string
"time": string
"message": string
"topics": string
}
/** One reply item from POST /rest/tool/ping */
export interface RosPingResult {
"host"?: string
"seq": string
"size"?: string
"ttl"?: string
"time"?: string
"sent"?: string
"received"?: string
"packet-loss"?: string
"status"?: string // "timeout" when lost
}
// ── BFD types ─────────────────────────────────────────────────────────────────
/** Raw RouterOS 7.x /rest/routing/bfd/session */
export interface RosBfdSession {
".id": string
"state": string // "up" | "down" | "init" | "admindown"
"up"?: string // "true" when up
"uptime"?: string // e.g. "6h15m29s"
"local-address": string // "10.100.2.13%NSK-SERVHOST-MTS" (IP%iface)
"remote-address": string // "10.100.2.14%NSK-SERVHOST-MTS"
"multihop"?: string // "true" | "false"
"multiplier"?: string // detection multiplier
"desired-tx-interval"?: string // "200ms"
"required-min-rx"?: string // "200ms"
"actual-tx-interval"?: string // "200ms"
"remote-min-rx"?: string // "200ms"
"remote-min-tx"?: string // "200ms"
"hold-time"?: string // "1s"
"packets-rx"?: string
"packets-tx"?: string
"state-changes"?: string
"vrf"?: string
}
export interface BfdSessionRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
localAddr: string // IP only, without %iface
remoteAddr: string // IP only
interface: string // extracted from %iface suffix
state: string // "Up" | "Down" | "Init" | "AdminDown"
uptime: string | null
multihop: boolean
multiplier: number
txInterval: number // ms
rxInterval: number // ms
holdTime: number // ms
packetsRx: number
packetsTx: number
stateChanges: number
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}