@usex/mikrotik-mcp 4.16.0 → 4.18.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/README.md CHANGED
@@ -385,6 +385,7 @@ source** (`bun run gen:schemas`) so they can never drift:
385
385
  | **[Firewall Audit](docs/firewall-audit.md)** | Shadowed/broad/dead rules, risk-scored |
386
386
  | **[Security Hardening](docs/security-hardening.md)** | Per-category audit+remediate, fix by finding_id, snapshot + Safe-Mode |
387
387
  | **[Port-Scan Detection](docs/port-scan-detection.md)** | Detect+tag six scan signatures behind a trust-excluding jump-gate |
388
+ | **[CAPsMAN Orchestrator](docs/capsman.md)** | Wi-Fi fabric audit: coverage/co-channel, weak signal, load, FT & HA |
388
389
  | **[Packet Capture Studio](docs/packet-capture.md)** | Live TZSP capture + pcap export |
389
390
  | **[Discovery](docs/discovery.md)** | `bun run discover`, MNDP neighbours, topology map |
390
391
  | **[Config Studio](docs/config-studio.md)** | Edit config in the dashboard with autocomplete |
package/dist/cli.js CHANGED
@@ -25,11 +25,19 @@ import {
25
25
  allToolModules,
26
26
  allowDevice,
27
27
  analyzeDrift,
28
+ applyWritesSafely,
28
29
  attributeChanges,
29
30
  backupDir,
30
31
  blockDevice,
31
32
  buildChangePlan,
33
+ buildChannelPlanCommands,
34
+ buildFtCommands,
35
+ buildHaCommands,
36
+ buildLoadBalanceCommands,
37
+ buildSteerCommands,
38
+ capsmanOverview,
32
39
  capture,
40
+ captureSnapshot,
33
41
  checkForUpdate,
34
42
  closeAll,
35
43
  closeMemoryStore,
@@ -46,6 +54,7 @@ import {
46
54
  diffLines,
47
55
  executeMikrotikCommand,
48
56
  fetchAllReleases,
57
+ fetchCapsmanState,
49
58
  fetchDevices,
50
59
  fetchLatestRelease,
51
60
  getConfig,
@@ -55,6 +64,7 @@ import {
55
64
  getRadiusIncoming,
56
65
  getS3Client,
57
66
  getUmSettings,
67
+ haGuidance,
58
68
  isEmpty,
59
69
  isMacTelnetDevice,
60
70
  isPoolEnabled,
@@ -62,6 +72,7 @@ import {
62
72
  listAaaEntity,
63
73
  listBackups,
64
74
  listDevices,
75
+ loadBalancePlan,
65
76
  loadConfig,
66
77
  loadFileCacheSync,
67
78
  logger,
@@ -70,6 +81,7 @@ import {
70
81
  moduleCatalog,
71
82
  normalizeExport,
72
83
  openSnapshotStore,
84
+ parseDisks,
73
85
  parseKeyValues,
74
86
  parseLeadingNumber,
75
87
  parseRecords,
@@ -87,10 +99,12 @@ import {
87
99
  renameBackup,
88
100
  renderPlan,
89
101
  reopenMemoryStore,
102
+ reportWeakClients,
90
103
  resetRadiusCounters,
91
104
  resolveDeviceName,
92
105
  restoreLocalBackup,
93
106
  riskOf,
107
+ runCapsmanAudit,
94
108
  s3Target,
95
109
  sampleAllTraffic,
96
110
  sampleDeviceTraffic,
@@ -102,13 +116,14 @@ import {
102
116
  setRadiusIncoming,
103
117
  setUmSettings,
104
118
  splitCommands,
119
+ steerAlreadyPresent,
105
120
  subscribe,
106
121
  subscriberCount,
107
122
  toggleAaaEntity,
108
123
  updateAaaEntity,
109
124
  updateSummaryLine,
110
125
  writeBackup
111
- } from "./shared/cli-w6ecrhxa.js";
126
+ } from "./shared/cli-dv2kmsh2.js";
112
127
 
113
128
  // src/cli.ts
114
129
  import { existsSync as existsSync2 } from "fs";
@@ -117,7 +132,7 @@ import { existsSync as existsSync2 } from "fs";
117
132
  import { spawn } from "child_process";
118
133
  import { readFileSync as readFileSync3 } from "fs";
119
134
  import { homedir, networkInterfaces } from "os";
120
- import { dirname as dirname4, join as join3 } from "path";
135
+ import { dirname as dirname5, join as join3 } from "path";
121
136
  var {serve } = globalThis.Bun;
122
137
  import { z as z2 } from "zod";
123
138
 
@@ -750,6 +765,9 @@ async function probeDevice(name, dc) {
750
765
  try {
751
766
  neighbors.set(name, parseNeighbors(await client.run("/ip neighbor print detail")));
752
767
  } catch {}
768
+ try {
769
+ status.disks = parseDisks(await client.run("/disk print detail"));
770
+ } catch {}
753
771
  }
754
772
  } catch (e) {
755
773
  status = {
@@ -1018,9 +1036,172 @@ async function openSqliteStore(path) {
1018
1036
  return new SqliteEventStore(db);
1019
1037
  }
1020
1038
 
1021
- // src/observability/usage-store.ts
1039
+ // src/observability/capsman-store.ts
1022
1040
  import { mkdirSync as mkdirSync4 } from "fs";
1023
1041
  import { dirname as dirname3 } from "path";
1042
+ function summariseRadioSamples(rows) {
1043
+ const byRadio = new Map;
1044
+ for (const r of rows) {
1045
+ const list = byRadio.get(r.radioId);
1046
+ if (list)
1047
+ list.push(r);
1048
+ else
1049
+ byRadio.set(r.radioId, [r]);
1050
+ }
1051
+ const series = [];
1052
+ for (const [radioId, list] of byRadio) {
1053
+ list.sort((a, b) => a.ts - b.ts);
1054
+ const last = list[list.length - 1];
1055
+ const points = list.map((r) => ({ ts: r.ts, clients: r.clients, channel: r.channel }));
1056
+ const peak = points.reduce((m, p) => Math.max(m, p.clients), 0);
1057
+ const total = points.reduce((s, p) => s + p.clients, 0);
1058
+ series.push({
1059
+ radioId,
1060
+ cap: last.cap,
1061
+ band: last.band,
1062
+ points,
1063
+ peak,
1064
+ avg: points.length ? Math.round(total / points.length) : 0
1065
+ });
1066
+ }
1067
+ return series.sort((a, b) => a.radioId.localeCompare(b.radioId));
1068
+ }
1069
+ var SCHEMA_STATEMENTS2 = [
1070
+ `CREATE TABLE IF NOT EXISTS capsman_samples (
1071
+ device TEXT NOT NULL,
1072
+ radio_id TEXT NOT NULL,
1073
+ cap TEXT NOT NULL,
1074
+ band TEXT NOT NULL,
1075
+ ts INTEGER NOT NULL,
1076
+ clients INTEGER NOT NULL,
1077
+ channel INTEGER
1078
+ )`,
1079
+ "CREATE INDEX IF NOT EXISTS idx_capsman_radio ON capsman_samples(device, radio_id, ts)",
1080
+ "CREATE INDEX IF NOT EXISTS idx_capsman_ts ON capsman_samples(ts)"
1081
+ ];
1082
+
1083
+ class SqliteCapsmanStore {
1084
+ db;
1085
+ constructor(db) {
1086
+ this.db = db;
1087
+ db.run("PRAGMA journal_mode = WAL");
1088
+ db.run("PRAGMA synchronous = NORMAL");
1089
+ for (const stmt of SCHEMA_STATEMENTS2)
1090
+ db.run(stmt);
1091
+ }
1092
+ recordRadioSamples(device, ts, samples) {
1093
+ if (samples.length === 0)
1094
+ return;
1095
+ const insert = this.db.query(`INSERT INTO capsman_samples (device, radio_id, cap, band, ts, clients, channel)
1096
+ VALUES ($d,$r,$cap,$band,$ts,$c,$ch)`);
1097
+ const tx = this.db.transaction((rows) => {
1098
+ for (const r of rows) {
1099
+ insert.run({
1100
+ $d: device,
1101
+ $r: r.radioId,
1102
+ $cap: r.cap,
1103
+ $band: r.band,
1104
+ $ts: ts,
1105
+ $c: r.clients,
1106
+ $ch: r.channel ?? null
1107
+ });
1108
+ }
1109
+ });
1110
+ tx(samples);
1111
+ }
1112
+ radioSeries(device, sinceTs) {
1113
+ const rows = this.db.query(`SELECT radio_id AS radioId, cap, band, ts, clients, channel
1114
+ FROM capsman_samples WHERE device=$d AND ts>=$since ORDER BY ts ASC`).all({ $d: device, $since: sinceTs });
1115
+ return summariseRadioSamples(rows);
1116
+ }
1117
+ pruneSamples(olderThanTs) {
1118
+ const res = this.db.query("DELETE FROM capsman_samples WHERE ts < $t").run({ $t: olderThanTs });
1119
+ return Number(res.changes ?? 0);
1120
+ }
1121
+ close() {
1122
+ this.db.close();
1123
+ }
1124
+ }
1125
+ async function openCapsmanStore(path) {
1126
+ if (path !== ":memory:") {
1127
+ try {
1128
+ mkdirSync4(dirname3(path), { recursive: true });
1129
+ } catch {}
1130
+ }
1131
+ const { Database } = await import("bun:sqlite");
1132
+ const db = new Database(path, { create: true });
1133
+ return new SqliteCapsmanStore(db);
1134
+ }
1135
+
1136
+ // src/observability/capsman-sampler.ts
1137
+ var SERVER_TAG = "mikrotik-mcp";
1138
+ var CAPSMAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1139
+ var DEFAULT_CAPSMAN_INTERVAL_MS = 5 * 60000;
1140
+ var MIN_CAPSMAN_INTERVAL_MS = 60000;
1141
+ var MAX_CAPSMAN_INTERVAL_MS = 6 * 60 * 60000;
1142
+ var timer2 = null;
1143
+ var inFlight2 = false;
1144
+ var noCapsman = new Set;
1145
+ function clampInterval(ms) {
1146
+ if (!Number.isFinite(ms))
1147
+ return DEFAULT_CAPSMAN_INTERVAL_MS;
1148
+ return Math.max(MIN_CAPSMAN_INTERVAL_MS, Math.min(MAX_CAPSMAN_INTERVAL_MS, Math.round(ms)));
1149
+ }
1150
+ async function sampleDevice(store, device, ts) {
1151
+ if (noCapsman.has(device))
1152
+ return;
1153
+ const ctx = createContext(undefined, device);
1154
+ const state = await fetchCapsmanState(ctx);
1155
+ if (state.path === null) {
1156
+ noCapsman.add(device);
1157
+ return;
1158
+ }
1159
+ const overview = capsmanOverview(state);
1160
+ const samples = overview.radios.map((r) => ({
1161
+ radioId: r.radioId,
1162
+ cap: r.cap,
1163
+ band: r.band,
1164
+ channel: r.channel,
1165
+ clients: r.clientCount
1166
+ }));
1167
+ store.recordRadioSamples(device, ts, samples);
1168
+ }
1169
+ async function sampleCapsmanOnce(store) {
1170
+ if (inFlight2)
1171
+ return;
1172
+ inFlight2 = true;
1173
+ const ts = Date.now();
1174
+ try {
1175
+ const cfg = getConfig();
1176
+ await Promise.all(Object.entries(cfg.devices).map(async ([name, dc]) => {
1177
+ if (dc.mac)
1178
+ return;
1179
+ try {
1180
+ await sampleDevice(store, name, ts);
1181
+ } catch (e) {
1182
+ logger.warn(`[${SERVER_TAG}] capsman sample failed for '${name}': ${String(e)}`);
1183
+ }
1184
+ }));
1185
+ store.pruneSamples(ts - CAPSMAN_RETENTION_MS);
1186
+ } finally {
1187
+ inFlight2 = false;
1188
+ }
1189
+ }
1190
+ function startCapsmanSampler(store, intervalMs = DEFAULT_CAPSMAN_INTERVAL_MS) {
1191
+ const ms = clampInterval(intervalMs);
1192
+ sampleCapsmanOnce(store);
1193
+ timer2 = setInterval(() => void sampleCapsmanOnce(store), ms);
1194
+ }
1195
+ function stopCapsmanSampler() {
1196
+ if (timer2) {
1197
+ clearInterval(timer2);
1198
+ timer2 = null;
1199
+ }
1200
+ }
1201
+
1202
+ // src/observability/usage-store.ts
1203
+ import { mkdirSync as mkdirSync5 } from "fs";
1204
+ import { dirname as dirname4 } from "path";
1024
1205
  function dayOf(ts) {
1025
1206
  return new Date(ts).toISOString().slice(0, 10);
1026
1207
  }
@@ -1039,7 +1220,7 @@ function dailyUsageFromSamples(samples) {
1039
1220
  }
1040
1221
  return [...byDay.entries()].map(([day, v]) => ({ day, rx: v.rx, tx: v.tx })).sort((a, b) => a.day.localeCompare(b.day));
1041
1222
  }
1042
- var SCHEMA_STATEMENTS2 = [
1223
+ var SCHEMA_STATEMENTS3 = [
1043
1224
  `CREATE TABLE IF NOT EXISTS usage_samples (
1044
1225
  device TEXT NOT NULL,
1045
1226
  subject TEXT NOT NULL,
@@ -1071,7 +1252,7 @@ class SqliteUsageStore {
1071
1252
  this.db = db;
1072
1253
  db.run("PRAGMA journal_mode = WAL");
1073
1254
  db.run("PRAGMA synchronous = NORMAL");
1074
- for (const stmt of SCHEMA_STATEMENTS2)
1255
+ for (const stmt of SCHEMA_STATEMENTS3)
1075
1256
  db.run(stmt);
1076
1257
  }
1077
1258
  recordClientSamples(device, ts, samples) {
@@ -1140,7 +1321,7 @@ class SqliteUsageStore {
1140
1321
  async function openUsageStore(path) {
1141
1322
  if (path !== ":memory:") {
1142
1323
  try {
1143
- mkdirSync4(dirname3(path), { recursive: true });
1324
+ mkdirSync5(dirname4(path), { recursive: true });
1144
1325
  } catch {}
1145
1326
  }
1146
1327
  const { Database } = await import("bun:sqlite");
@@ -1149,16 +1330,16 @@ async function openUsageStore(path) {
1149
1330
  }
1150
1331
 
1151
1332
  // src/observability/usage-sampler.ts
1152
- var SERVER_TAG = "mikrotik-mcp";
1333
+ var SERVER_TAG2 = "mikrotik-mcp";
1153
1334
  var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
1154
1335
  var DEFAULT_USAGE_INTERVAL_MS = 60000;
1155
1336
  var MIN_USAGE_INTERVAL_MS = 30000;
1156
1337
  var MAX_USAGE_INTERVAL_MS = 6 * 60 * 60000;
1157
- var timer2 = null;
1158
- var inFlight2 = false;
1338
+ var timer3 = null;
1339
+ var inFlight3 = false;
1159
1340
  var currentStore = null;
1160
1341
  var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
1161
- function clampInterval(ms) {
1342
+ function clampInterval2(ms) {
1162
1343
  if (!Number.isFinite(ms))
1163
1344
  return DEFAULT_USAGE_INTERVAL_MS;
1164
1345
  return Math.max(MIN_USAGE_INTERVAL_MS, Math.min(MAX_USAGE_INTERVAL_MS, Math.round(ms)));
@@ -1216,9 +1397,9 @@ async function ingestSessions(store, device) {
1216
1397
  store.upsertSessions(device, sessions);
1217
1398
  }
1218
1399
  async function sampleUsageOnce(store) {
1219
- if (inFlight2)
1400
+ if (inFlight3)
1220
1401
  return;
1221
- inFlight2 = true;
1402
+ inFlight3 = true;
1222
1403
  const ts = Date.now();
1223
1404
  try {
1224
1405
  const cfg = getConfig();
@@ -1229,37 +1410,37 @@ async function sampleUsageOnce(store) {
1229
1410
  await sampleClients(store, name, ts);
1230
1411
  await ingestSessions(store, name);
1231
1412
  } catch (e) {
1232
- logger.warn(`[${SERVER_TAG}] usage sample failed for '${name}': ${String(e)}`);
1413
+ logger.warn(`[${SERVER_TAG2}] usage sample failed for '${name}': ${String(e)}`);
1233
1414
  }
1234
1415
  }));
1235
1416
  store.pruneSamples(ts - USAGE_RETENTION_MS);
1236
1417
  } finally {
1237
- inFlight2 = false;
1418
+ inFlight3 = false;
1238
1419
  }
1239
1420
  }
1240
1421
  function startUsageSampler(store, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
1241
1422
  currentStore = store;
1242
- currentIntervalMs = clampInterval(intervalMs);
1423
+ currentIntervalMs = clampInterval2(intervalMs);
1243
1424
  sampleUsageOnce(store);
1244
- timer2 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1425
+ timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1245
1426
  }
1246
1427
  function getUsageSamplerInterval() {
1247
1428
  return currentIntervalMs;
1248
1429
  }
1249
1430
  function setUsageSamplerInterval(intervalMs) {
1250
- currentIntervalMs = clampInterval(intervalMs);
1251
- if (timer2)
1252
- clearInterval(timer2);
1431
+ currentIntervalMs = clampInterval2(intervalMs);
1432
+ if (timer3)
1433
+ clearInterval(timer3);
1253
1434
  if (currentStore) {
1254
1435
  const store = currentStore;
1255
- timer2 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1436
+ timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1256
1437
  }
1257
1438
  return currentIntervalMs;
1258
1439
  }
1259
1440
  function stopUsageSampler() {
1260
- if (timer2) {
1261
- clearInterval(timer2);
1262
- timer2 = null;
1441
+ if (timer3) {
1442
+ clearInterval(timer3);
1443
+ timer3 = null;
1263
1444
  }
1264
1445
  currentStore = null;
1265
1446
  }
@@ -1621,7 +1802,7 @@ async function memoryRoutes(req, url) {
1621
1802
  }
1622
1803
 
1623
1804
  // src/observability/dashboard.ts
1624
- var SERVER_TAG2 = "mikrotik-mcp";
1805
+ var SERVER_TAG3 = "mikrotik-mcp";
1625
1806
  var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
1626
1807
  function json3(body, status = 200) {
1627
1808
  return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS3 });
@@ -1652,7 +1833,7 @@ function runUpgrade(spec) {
1652
1833
  env: process.env,
1653
1834
  stdio: ["ignore", "pipe", "pipe"]
1654
1835
  });
1655
- const timer3 = setTimeout(() => {
1836
+ const timer4 = setTimeout(() => {
1656
1837
  child.kill();
1657
1838
  resolve({ ok: false, log: `${out}
1658
1839
  (timed out after 180s)` });
@@ -1660,12 +1841,12 @@ function runUpgrade(spec) {
1660
1841
  child.stdout?.on("data", (d) => out += d.toString());
1661
1842
  child.stderr?.on("data", (d) => out += d.toString());
1662
1843
  child.on("error", (e) => {
1663
- clearTimeout(timer3);
1844
+ clearTimeout(timer4);
1664
1845
  resolve({ ok: false, log: `${out}
1665
1846
  spawn error: ${String(e)}` });
1666
1847
  });
1667
1848
  child.on("exit", (code) => {
1668
- clearTimeout(timer3);
1849
+ clearTimeout(timer4);
1669
1850
  resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
1670
1851
  });
1671
1852
  });
@@ -1782,7 +1963,7 @@ function devicesPayload(store) {
1782
1963
  }
1783
1964
  };
1784
1965
  });
1785
- return { server: SERVER_TAG2, defaultDevice: cfg.defaultDevice, devices };
1966
+ return { server: SERVER_TAG3, defaultDevice: cfg.defaultDevice, devices };
1786
1967
  }
1787
1968
  function topologyPayload() {
1788
1969
  const cfg = getConfig();
@@ -1795,7 +1976,7 @@ function topologyPayload() {
1795
1976
  for (const { name } of devices)
1796
1977
  neighborsByDevice[name] = getDeviceNeighbors(name);
1797
1978
  return {
1798
- server: SERVER_TAG2,
1979
+ server: SERVER_TAG3,
1799
1980
  defaultDevice: cfg.defaultDevice,
1800
1981
  generatedAt: Date.now(),
1801
1982
  ...buildTopology({ devices, neighborsByDevice })
@@ -2178,6 +2359,99 @@ async function captureRoutes(req, url) {
2178
2359
  }
2179
2360
  return null;
2180
2361
  }
2362
+ var capsmanStore = null;
2363
+ async function capsmanRoutes(req, url) {
2364
+ const p = url.pathname;
2365
+ if (!p.startsWith("/api/capsman"))
2366
+ return null;
2367
+ if (req.method === "GET") {
2368
+ const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
2369
+ if (p === "/api/capsman/overview") {
2370
+ return json3(capsmanOverview(await fetchCapsmanState(ctx)));
2371
+ }
2372
+ if (p === "/api/capsman/clients") {
2373
+ const state = await fetchCapsmanState(ctx);
2374
+ const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
2375
+ const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
2376
+ return json3({ clients: state.clients, weak });
2377
+ }
2378
+ if (p === "/api/capsman/audit") {
2379
+ return json3(runCapsmanAudit(await fetchCapsmanState(ctx)));
2380
+ }
2381
+ if (p === "/api/capsman/trends") {
2382
+ if (!capsmanStore)
2383
+ return json3({ error: "capsman store not active" }, 503);
2384
+ const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2385
+ const days = daysParam(url, 7, 30);
2386
+ const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
2387
+ return json3({ series, days });
2388
+ }
2389
+ return null;
2390
+ }
2391
+ if (req.method === "POST") {
2392
+ const body = await readJson(req);
2393
+ const ctx = createContext(undefined, body?.device);
2394
+ const state = await fetchCapsmanState(ctx);
2395
+ if (p === "/api/capsman/apply/steer") {
2396
+ const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
2397
+ if (!client)
2398
+ return json3({ ok: false, error: "client not associated" }, 400);
2399
+ if (steerAlreadyPresent(state, body.mac ?? "")) {
2400
+ return json3({ ok: true, message: "already steered (no-op)" });
2401
+ }
2402
+ const mode = body.mode === "soft" ? "soft" : "hard";
2403
+ const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
2404
+ if (!body.confirm)
2405
+ return json3({ ok: true, preview: commands });
2406
+ return json3(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
2407
+ }
2408
+ if (p === "/api/capsman/apply/load-balance") {
2409
+ const plan = loadBalancePlan(state);
2410
+ const commands = buildLoadBalanceCommands(state, plan);
2411
+ if (!body.confirm)
2412
+ return json3({ ok: true, preview: commands, plan });
2413
+ return json3(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
2414
+ }
2415
+ if (p === "/api/capsman/apply/channel-plan") {
2416
+ if (state.path === "/caps-man") {
2417
+ return json3({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
2418
+ }
2419
+ const commands = buildChannelPlanCommands(state);
2420
+ if (!body.confirm)
2421
+ return json3({ ok: true, preview: commands });
2422
+ return json3(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
2423
+ }
2424
+ if (p === "/api/capsman/apply/ft") {
2425
+ const commands = buildFtCommands(state);
2426
+ if (!body.confirm)
2427
+ return json3({ ok: true, preview: commands });
2428
+ return json3(await applyCapsmanWrites(ctx, commands, "pre-ft"));
2429
+ }
2430
+ if (p === "/api/capsman/apply/ha") {
2431
+ const commands = buildHaCommands(state);
2432
+ const guidance = haGuidance(state);
2433
+ if (!body.confirm)
2434
+ return json3({ ok: true, preview: commands, guidance });
2435
+ const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
2436
+ return json3({ ...res, guidance });
2437
+ }
2438
+ }
2439
+ return null;
2440
+ }
2441
+ async function applyCapsmanWrites(ctx, commands, label) {
2442
+ if (commands.length === 0)
2443
+ return { ok: true, applied: 0 };
2444
+ const snapshotId = await captureSnapshot(ctx, label);
2445
+ const device = resolveDeviceName(ctx.device);
2446
+ const outcome = await applyWritesSafely(ctx, device, commands, { allowDirectFallback: false });
2447
+ return {
2448
+ ok: outcome.committed && !outcome.error,
2449
+ snapshotId,
2450
+ safeMode: outcome.safeMode,
2451
+ applied: outcome.applied,
2452
+ error: outcome.error
2453
+ };
2454
+ }
2181
2455
  async function clientsRoutes(req, url) {
2182
2456
  const p = url.pathname;
2183
2457
  if (!p.startsWith("/api/clients"))
@@ -2566,16 +2840,22 @@ async function runDashboard(cfg, transportLabel) {
2566
2840
  });
2567
2841
  startHealthChecks(30000);
2568
2842
  try {
2569
- usageStore = await openUsageStore(join3(dirname4(cfg.dbPath), "usage.db"));
2843
+ usageStore = await openUsageStore(join3(dirname5(cfg.dbPath), "usage.db"));
2570
2844
  startUsageSampler(usageStore);
2571
2845
  } catch (e) {
2572
- logger.warn(`[${SERVER_TAG2}] usage history disabled: ${String(e)}`);
2846
+ logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
2847
+ }
2848
+ try {
2849
+ capsmanStore = await openCapsmanStore(join3(dirname5(cfg.dbPath), "capsman.db"));
2850
+ startCapsmanSampler(capsmanStore);
2851
+ } catch (e) {
2852
+ logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
2573
2853
  }
2574
2854
  try {
2575
2855
  if (isEmpty2())
2576
2856
  recordVersion(getConfig(), "auto", Date.now(), "baseline");
2577
2857
  } catch (e) {
2578
- logger.warn(`[${SERVER_TAG2}] could not seed config history baseline: ${String(e)}`);
2858
+ logger.warn(`[${SERVER_TAG3}] could not seed config history baseline: ${String(e)}`);
2579
2859
  }
2580
2860
  const configAdmin = createConfigAdmin({
2581
2861
  getConfig,
@@ -2622,6 +2902,9 @@ async function runDashboard(cfg, transportLabel) {
2622
2902
  const captureResp = await captureRoutes(req, url);
2623
2903
  if (captureResp)
2624
2904
  return captureResp;
2905
+ const capsmanResp = await capsmanRoutes(req, url);
2906
+ if (capsmanResp)
2907
+ return capsmanResp;
2625
2908
  const clientsResp = await clientsRoutes(req, url);
2626
2909
  if (clientsResp)
2627
2910
  return clientsResp;
@@ -2875,10 +3158,13 @@ async function runDashboard(cfg, transportLabel) {
2875
3158
  stop() {
2876
3159
  stopHealthChecks();
2877
3160
  stopUsageSampler();
3161
+ stopCapsmanSampler();
2878
3162
  server.stop(true);
2879
3163
  store.close();
2880
3164
  usageStore?.close();
2881
3165
  usageStore = null;
3166
+ capsmanStore?.close();
3167
+ capsmanStore = null;
2882
3168
  closeMemoryStore();
2883
3169
  }
2884
3170
  };
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  selectToolModules,
30
30
  setConfig,
31
31
  updateSummaryLine
32
- } from "./shared/library-212xbkwr.js";
32
+ } from "./shared/library-7m2c1g1y.js";
33
33
  // src/server.ts
34
34
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
35
35
  import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./cli-w6ecrhxa.js";
7
+ } from "./cli-dv2kmsh2.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,