@usex/mikrotik-mcp 3.25.0 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -24328,7 +24328,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
24328
24328
  // src/observability/dashboard.ts
24329
24329
  import { readFileSync as readFileSync6 } from "fs";
24330
24330
  import { homedir as homedir2, networkInterfaces } from "os";
24331
- import { join as join6 } from "path";
24331
+ import { dirname as dirname6, join as join6 } from "path";
24332
24332
  var {serve } = globalThis.Bun;
24333
24333
  import { z as z114 } from "zod";
24334
24334
 
@@ -24975,6 +24975,219 @@ async function openSqliteStore(path) {
24975
24975
  return new SqliteEventStore(db);
24976
24976
  }
24977
24977
 
24978
+ // src/observability/usage-store.ts
24979
+ import { mkdirSync as mkdirSync6 } from "fs";
24980
+ import { dirname as dirname5 } from "path";
24981
+ function dayOf(ts) {
24982
+ return new Date(ts).toISOString().slice(0, 10);
24983
+ }
24984
+ function dailyUsageFromSamples(samples) {
24985
+ const byDay = new Map;
24986
+ for (let i = 1;i < samples.length; i++) {
24987
+ const prev = samples[i - 1];
24988
+ const cur = samples[i];
24989
+ const dRx = cur.rx >= prev.rx ? cur.rx - prev.rx : cur.rx;
24990
+ const dTx = cur.tx >= prev.tx ? cur.tx - prev.tx : cur.tx;
24991
+ const day = dayOf(cur.ts);
24992
+ const acc = byDay.get(day) ?? { rx: 0, tx: 0 };
24993
+ acc.rx += dRx;
24994
+ acc.tx += dTx;
24995
+ byDay.set(day, acc);
24996
+ }
24997
+ return [...byDay.entries()].map(([day, v]) => ({ day, rx: v.rx, tx: v.tx })).sort((a, b) => a.day.localeCompare(b.day));
24998
+ }
24999
+ var SCHEMA_STATEMENTS3 = [
25000
+ `CREATE TABLE IF NOT EXISTS usage_samples (
25001
+ device TEXT NOT NULL,
25002
+ subject TEXT NOT NULL,
25003
+ ts INTEGER NOT NULL,
25004
+ rx INTEGER NOT NULL,
25005
+ tx INTEGER NOT NULL
25006
+ )`,
25007
+ "CREATE INDEX IF NOT EXISTS idx_usage_sub ON usage_samples(device, subject, ts)",
25008
+ "CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_samples(ts)",
25009
+ `CREATE TABLE IF NOT EXISTS vpn_sessions (
25010
+ device TEXT NOT NULL,
25011
+ session_id TEXT NOT NULL,
25012
+ user TEXT NOT NULL,
25013
+ service TEXT,
25014
+ nas TEXT,
25015
+ started INTEGER NOT NULL,
25016
+ day TEXT NOT NULL,
25017
+ rx INTEGER NOT NULL,
25018
+ tx INTEGER NOT NULL,
25019
+ PRIMARY KEY (device, session_id)
25020
+ )`,
25021
+ "CREATE INDEX IF NOT EXISTS idx_sess_user ON vpn_sessions(device, user, day)",
25022
+ "CREATE INDEX IF NOT EXISTS idx_sess_day ON vpn_sessions(device, day)"
25023
+ ];
25024
+
25025
+ class SqliteUsageStore {
25026
+ db;
25027
+ constructor(db) {
25028
+ this.db = db;
25029
+ db.run("PRAGMA journal_mode = WAL");
25030
+ db.run("PRAGMA synchronous = NORMAL");
25031
+ for (const stmt of SCHEMA_STATEMENTS3)
25032
+ db.run(stmt);
25033
+ }
25034
+ recordClientSamples(device, ts, samples) {
25035
+ if (samples.length === 0)
25036
+ return;
25037
+ const insert = this.db.query("INSERT INTO usage_samples (device, subject, ts, rx, tx) VALUES ($d,$s,$ts,$rx,$tx)");
25038
+ const tx = this.db.transaction((rows) => {
25039
+ for (const r of rows) {
25040
+ insert.run({ $d: device, $s: r.ip, $ts: ts, $rx: r.rx, $tx: r.tx });
25041
+ }
25042
+ });
25043
+ tx(samples);
25044
+ }
25045
+ upsertSessions(device, sessions) {
25046
+ if (sessions.length === 0)
25047
+ return 0;
25048
+ const stmt = this.db.query(`INSERT INTO vpn_sessions (device, session_id, user, service, nas, started, day, rx, tx)
25049
+ VALUES ($d,$id,$u,$svc,$nas,$st,$day,$rx,$tx)
25050
+ ON CONFLICT(device, session_id) DO UPDATE SET rx=excluded.rx, tx=excluded.tx`);
25051
+ let n = 0;
25052
+ const tx = this.db.transaction((rows) => {
25053
+ for (const s of rows) {
25054
+ stmt.run({
25055
+ $d: device,
25056
+ $id: s.sessionId,
25057
+ $u: s.user,
25058
+ $svc: s.service ?? null,
25059
+ $nas: s.nas ?? null,
25060
+ $st: s.started,
25061
+ $day: dayOf(s.started),
25062
+ $rx: s.rx,
25063
+ $tx: s.tx
25064
+ });
25065
+ n++;
25066
+ }
25067
+ });
25068
+ tx(sessions);
25069
+ return n;
25070
+ }
25071
+ clientDailyUsage(device, ip, sinceTs) {
25072
+ const rows = this.db.query("SELECT ts, rx, tx FROM usage_samples WHERE device=$d AND subject=$s AND ts>=$since ORDER BY ts ASC").all({ $d: device, $s: ip, $since: sinceTs });
25073
+ return dailyUsageFromSamples(rows);
25074
+ }
25075
+ umUserDailyUsage(device, user, sinceTs) {
25076
+ const rows = this.db.query(`SELECT day, SUM(rx) AS rx, SUM(tx) AS tx FROM vpn_sessions
25077
+ WHERE device=$d AND user=$u AND started>=$since GROUP BY day ORDER BY day ASC`).all({ $d: device, $u: user, $since: sinceTs });
25078
+ return rows.map((r) => ({ day: r.day, rx: Number(r.rx), tx: Number(r.tx) }));
25079
+ }
25080
+ umUsers(device) {
25081
+ const rows = this.db.query("SELECT DISTINCT user FROM vpn_sessions WHERE device=$d ORDER BY user ASC").all({ $d: device });
25082
+ return rows.map((r) => r.user);
25083
+ }
25084
+ heatmap(device, user, sinceTs) {
25085
+ const sinceDay = dayOf(sinceTs);
25086
+ const rows = user ? this.db.query("SELECT day, COUNT(*) AS count FROM vpn_sessions WHERE device=$d AND user=$u AND day>=$since GROUP BY day").all({ $d: device, $u: user, $since: sinceDay }) : this.db.query("SELECT day, COUNT(*) AS count FROM vpn_sessions WHERE device=$d AND day>=$since GROUP BY day").all({ $d: device, $since: sinceDay });
25087
+ return rows.map((r) => ({ day: r.day, count: Number(r.count) }));
25088
+ }
25089
+ pruneSamples(olderThanTs) {
25090
+ const res = this.db.query("DELETE FROM usage_samples WHERE ts < $t").run({ $t: olderThanTs });
25091
+ return Number(res.changes ?? 0);
25092
+ }
25093
+ close() {
25094
+ this.db.close();
25095
+ }
25096
+ }
25097
+ async function openUsageStore(path) {
25098
+ if (path !== ":memory:") {
25099
+ try {
25100
+ mkdirSync6(dirname5(path), { recursive: true });
25101
+ } catch {}
25102
+ }
25103
+ const { Database } = await import("bun:sqlite");
25104
+ const db = new Database(path, { create: true });
25105
+ return new SqliteUsageStore(db);
25106
+ }
25107
+
25108
+ // src/observability/usage-sampler.ts
25109
+ var SERVER_TAG = "mikrotik-mcp";
25110
+ var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
25111
+ var timer2 = null;
25112
+ var inFlight2 = false;
25113
+ function bytesOf(v) {
25114
+ return parseSize(v) ?? parseLeadingNumber(v) ?? 0;
25115
+ }
25116
+ function ipOf(target) {
25117
+ return (target ?? "").split("/")[0]?.trim() ?? "";
25118
+ }
25119
+ async function sampleClients(store2, device, ts) {
25120
+ const ctx = createContext(undefined, device);
25121
+ const out = await executeMikrotikCommand("/queue simple print stats detail", ctx);
25122
+ if (isEmpty(out) || looksLikeError(out) || commandUnsupported(out))
25123
+ return;
25124
+ const samples = [];
25125
+ for (const row of parseRecords(out).rows) {
25126
+ const ip = ipOf(row.target ?? "");
25127
+ if (!ip)
25128
+ continue;
25129
+ const [tx, rx] = (row.bytes ?? "0/0").split("/");
25130
+ samples.push({ ip, rx: bytesOf(rx), tx: bytesOf(tx) });
25131
+ }
25132
+ store2.recordClientSamples(device, ts, samples);
25133
+ }
25134
+ async function ingestSessions(store2, device) {
25135
+ const ctx = createContext(undefined, device);
25136
+ const out = await executeMikrotikCommand("/user-manager session print detail", ctx);
25137
+ if (isEmpty(out) || looksLikeError(out) || commandUnsupported(out))
25138
+ return;
25139
+ const sessions = [];
25140
+ for (const row of parseRecords(out).rows) {
25141
+ const user = row.user ?? "";
25142
+ const started = parseRouterosDate(row.started ?? row["start-time"]);
25143
+ if (!user || started == null)
25144
+ continue;
25145
+ const sessionId = row["acct-session-id"] || `${user}|${started}|${row["calling-station-id"] ?? row["nas-port-id"] ?? ""}`;
25146
+ sessions.push({
25147
+ sessionId,
25148
+ user,
25149
+ service: row.service,
25150
+ nas: row["nas-ip-address"] ?? row["nas-port-id"],
25151
+ started,
25152
+ rx: bytesOf(row.download),
25153
+ tx: bytesOf(row.upload)
25154
+ });
25155
+ }
25156
+ store2.upsertSessions(device, sessions);
25157
+ }
25158
+ async function sampleUsageOnce(store2) {
25159
+ if (inFlight2)
25160
+ return;
25161
+ inFlight2 = true;
25162
+ const ts = Date.now();
25163
+ try {
25164
+ const cfg = getConfig();
25165
+ await Promise.all(Object.entries(cfg.devices).map(async ([name, dc]) => {
25166
+ if (dc.mac)
25167
+ return;
25168
+ try {
25169
+ await sampleClients(store2, name, ts);
25170
+ await ingestSessions(store2, name);
25171
+ } catch (e) {
25172
+ logger.warn(`[${SERVER_TAG}] usage sample failed for '${name}': ${String(e)}`);
25173
+ }
25174
+ }));
25175
+ store2.pruneSamples(ts - USAGE_RETENTION_MS);
25176
+ } finally {
25177
+ inFlight2 = false;
25178
+ }
25179
+ }
25180
+ function startUsageSampler(store2, intervalMs = 10 * 60000) {
25181
+ sampleUsageOnce(store2);
25182
+ timer2 = setInterval(() => void sampleUsageOnce(store2), intervalMs);
25183
+ }
25184
+ function stopUsageSampler() {
25185
+ if (timer2) {
25186
+ clearInterval(timer2);
25187
+ timer2 = null;
25188
+ }
25189
+ }
25190
+
24978
25191
  // src/observability/stats.ts
24979
25192
  function percentile(values, p) {
24980
25193
  if (values.length === 0)
@@ -25069,7 +25282,7 @@ function computeStats(events, opts) {
25069
25282
  }
25070
25283
 
25071
25284
  // src/observability/dashboard.ts
25072
- var SERVER_TAG = "mikrotik-mcp";
25285
+ var SERVER_TAG2 = "mikrotik-mcp";
25073
25286
  var JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
25074
25287
  function json(body, status = 200) {
25075
25288
  return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
@@ -25170,7 +25383,7 @@ function devicesPayload(store2) {
25170
25383
  avgMs: 0
25171
25384
  }
25172
25385
  }));
25173
- return { server: SERVER_TAG, defaultDevice: cfg.defaultDevice, devices };
25386
+ return { server: SERVER_TAG2, defaultDevice: cfg.defaultDevice, devices };
25174
25387
  }
25175
25388
  function topologyPayload() {
25176
25389
  const cfg = getConfig();
@@ -25183,7 +25396,7 @@ function topologyPayload() {
25183
25396
  for (const { name } of devices)
25184
25397
  neighborsByDevice[name] = getDeviceNeighbors(name);
25185
25398
  return {
25186
- server: SERVER_TAG,
25399
+ server: SERVER_TAG2,
25187
25400
  defaultDevice: cfg.defaultDevice,
25188
25401
  generatedAt: Date.now(),
25189
25402
  ...buildTopology({ devices, neighborsByDevice })
@@ -25528,6 +25741,54 @@ async function aaaRoutes(req, url) {
25528
25741
  }
25529
25742
  return null;
25530
25743
  }
25744
+ var usageStore = null;
25745
+ function daysParam(url, fallback, max) {
25746
+ const n = Number(url.searchParams.get("days"));
25747
+ return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
25748
+ }
25749
+ function usageRoutes(req, url) {
25750
+ const p = url.pathname;
25751
+ if (!p.startsWith("/api/usage"))
25752
+ return null;
25753
+ if (req.method !== "GET")
25754
+ return null;
25755
+ if (!usageStore)
25756
+ return json({ error: "usage store not active" }, 503);
25757
+ const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
25758
+ const sinceTs = (days) => Date.now() - days * 86400000;
25759
+ if (p === "/api/usage/um-users") {
25760
+ return json({ users: usageStore.umUsers(device) });
25761
+ }
25762
+ if (p === "/api/usage/client") {
25763
+ const ip = url.searchParams.get("ip");
25764
+ if (!ip)
25765
+ return json({ error: "ip required" }, 400);
25766
+ const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
25767
+ return json(withTotals(series));
25768
+ }
25769
+ if (p === "/api/usage/um-user") {
25770
+ const user = url.searchParams.get("user");
25771
+ if (!user)
25772
+ return json({ error: "user required" }, 400);
25773
+ const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
25774
+ return json(withTotals(series));
25775
+ }
25776
+ if (p === "/api/usage/heatmap") {
25777
+ const user = url.searchParams.get("user");
25778
+ const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
25779
+ const total = days.reduce((s, d) => s + d.count, 0);
25780
+ const max = days.reduce((m, d) => Math.max(m, d.count), 0);
25781
+ return json({ days, total, max });
25782
+ }
25783
+ return null;
25784
+ }
25785
+ function withTotals(series) {
25786
+ return {
25787
+ series,
25788
+ totalRx: series.reduce((s, d) => s + d.rx, 0),
25789
+ totalTx: series.reduce((s, d) => s + d.tx, 0)
25790
+ };
25791
+ }
25531
25792
  var snapStorePromise = null;
25532
25793
  function snapStore() {
25533
25794
  if (!snapStorePromise)
@@ -25746,11 +26007,17 @@ async function runDashboard(cfg, transportLabel) {
25746
26007
  transport: transportLabel
25747
26008
  });
25748
26009
  startHealthChecks(30000);
26010
+ try {
26011
+ usageStore = await openUsageStore(join6(dirname6(cfg.dbPath), "usage.db"));
26012
+ startUsageSampler(usageStore);
26013
+ } catch (e) {
26014
+ logger.warn(`[${SERVER_TAG2}] usage history disabled: ${String(e)}`);
26015
+ }
25749
26016
  try {
25750
26017
  if (isEmpty2())
25751
26018
  recordVersion(getConfig(), "auto", Date.now(), "baseline");
25752
26019
  } catch (e) {
25753
- logger.warn(`[${SERVER_TAG}] could not seed config history baseline: ${String(e)}`);
26020
+ logger.warn(`[${SERVER_TAG2}] could not seed config history baseline: ${String(e)}`);
25754
26021
  }
25755
26022
  const configAdmin = createConfigAdmin({
25756
26023
  getConfig,
@@ -25808,6 +26075,9 @@ async function runDashboard(cfg, transportLabel) {
25808
26075
  const aaaResp = await aaaRoutes(req, url);
25809
26076
  if (aaaResp)
25810
26077
  return aaaResp;
26078
+ const usageResp = usageRoutes(req, url);
26079
+ if (usageResp)
26080
+ return usageResp;
25811
26081
  const featureResp = await featureRoutes(req, url);
25812
26082
  if (featureResp)
25813
26083
  return featureResp;
@@ -25894,8 +26164,11 @@ async function runDashboard(cfg, transportLabel) {
25894
26164
  store: store2,
25895
26165
  stop() {
25896
26166
  stopHealthChecks();
26167
+ stopUsageSampler();
25897
26168
  server.stop(true);
25898
26169
  store2.close();
26170
+ usageStore?.close();
26171
+ usageStore = null;
25899
26172
  }
25900
26173
  };
25901
26174
  }
@@ -26052,7 +26325,7 @@ function registerPrompts(server) {
26052
26325
  // package.json
26053
26326
  var package_default = {
26054
26327
  name: "@usex/mikrotik-mcp",
26055
- version: "3.25.0",
26328
+ version: "3.26.0",
26056
26329
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
26057
26330
  keywords: [
26058
26331
  "ai",
package/dist/index.js CHANGED
@@ -24339,7 +24339,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
24339
24339
  // package.json
24340
24340
  var package_default = {
24341
24341
  name: "@usex/mikrotik-mcp",
24342
- version: "3.25.0",
24342
+ version: "3.26.0",
24343
24343
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
24344
24344
  keywords: [
24345
24345
  "ai",