@usex/mikrotik-mcp 4.15.0 → 4.17.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,
@@ -87,10 +98,12 @@ import {
87
98
  renameBackup,
88
99
  renderPlan,
89
100
  reopenMemoryStore,
101
+ reportWeakClients,
90
102
  resetRadiusCounters,
91
103
  resolveDeviceName,
92
104
  restoreLocalBackup,
93
105
  riskOf,
106
+ runCapsmanAudit,
94
107
  s3Target,
95
108
  sampleAllTraffic,
96
109
  sampleDeviceTraffic,
@@ -102,13 +115,14 @@ import {
102
115
  setRadiusIncoming,
103
116
  setUmSettings,
104
117
  splitCommands,
118
+ steerAlreadyPresent,
105
119
  subscribe,
106
120
  subscriberCount,
107
121
  toggleAaaEntity,
108
122
  updateAaaEntity,
109
123
  updateSummaryLine,
110
124
  writeBackup
111
- } from "./shared/cli-w6ecrhxa.js";
125
+ } from "./shared/cli-v75jtv6p.js";
112
126
 
113
127
  // src/cli.ts
114
128
  import { existsSync as existsSync2 } from "fs";
@@ -117,7 +131,7 @@ import { existsSync as existsSync2 } from "fs";
117
131
  import { spawn } from "child_process";
118
132
  import { readFileSync as readFileSync3 } from "fs";
119
133
  import { homedir, networkInterfaces } from "os";
120
- import { dirname as dirname4, join as join3 } from "path";
134
+ import { dirname as dirname5, join as join3 } from "path";
121
135
  var {serve } = globalThis.Bun;
122
136
  import { z as z2 } from "zod";
123
137
 
@@ -1018,9 +1032,172 @@ async function openSqliteStore(path) {
1018
1032
  return new SqliteEventStore(db);
1019
1033
  }
1020
1034
 
1021
- // src/observability/usage-store.ts
1035
+ // src/observability/capsman-store.ts
1022
1036
  import { mkdirSync as mkdirSync4 } from "fs";
1023
1037
  import { dirname as dirname3 } from "path";
1038
+ function summariseRadioSamples(rows) {
1039
+ const byRadio = new Map;
1040
+ for (const r of rows) {
1041
+ const list = byRadio.get(r.radioId);
1042
+ if (list)
1043
+ list.push(r);
1044
+ else
1045
+ byRadio.set(r.radioId, [r]);
1046
+ }
1047
+ const series = [];
1048
+ for (const [radioId, list] of byRadio) {
1049
+ list.sort((a, b) => a.ts - b.ts);
1050
+ const last = list[list.length - 1];
1051
+ const points = list.map((r) => ({ ts: r.ts, clients: r.clients, channel: r.channel }));
1052
+ const peak = points.reduce((m, p) => Math.max(m, p.clients), 0);
1053
+ const total = points.reduce((s, p) => s + p.clients, 0);
1054
+ series.push({
1055
+ radioId,
1056
+ cap: last.cap,
1057
+ band: last.band,
1058
+ points,
1059
+ peak,
1060
+ avg: points.length ? Math.round(total / points.length) : 0
1061
+ });
1062
+ }
1063
+ return series.sort((a, b) => a.radioId.localeCompare(b.radioId));
1064
+ }
1065
+ var SCHEMA_STATEMENTS2 = [
1066
+ `CREATE TABLE IF NOT EXISTS capsman_samples (
1067
+ device TEXT NOT NULL,
1068
+ radio_id TEXT NOT NULL,
1069
+ cap TEXT NOT NULL,
1070
+ band TEXT NOT NULL,
1071
+ ts INTEGER NOT NULL,
1072
+ clients INTEGER NOT NULL,
1073
+ channel INTEGER
1074
+ )`,
1075
+ "CREATE INDEX IF NOT EXISTS idx_capsman_radio ON capsman_samples(device, radio_id, ts)",
1076
+ "CREATE INDEX IF NOT EXISTS idx_capsman_ts ON capsman_samples(ts)"
1077
+ ];
1078
+
1079
+ class SqliteCapsmanStore {
1080
+ db;
1081
+ constructor(db) {
1082
+ this.db = db;
1083
+ db.run("PRAGMA journal_mode = WAL");
1084
+ db.run("PRAGMA synchronous = NORMAL");
1085
+ for (const stmt of SCHEMA_STATEMENTS2)
1086
+ db.run(stmt);
1087
+ }
1088
+ recordRadioSamples(device, ts, samples) {
1089
+ if (samples.length === 0)
1090
+ return;
1091
+ const insert = this.db.query(`INSERT INTO capsman_samples (device, radio_id, cap, band, ts, clients, channel)
1092
+ VALUES ($d,$r,$cap,$band,$ts,$c,$ch)`);
1093
+ const tx = this.db.transaction((rows) => {
1094
+ for (const r of rows) {
1095
+ insert.run({
1096
+ $d: device,
1097
+ $r: r.radioId,
1098
+ $cap: r.cap,
1099
+ $band: r.band,
1100
+ $ts: ts,
1101
+ $c: r.clients,
1102
+ $ch: r.channel ?? null
1103
+ });
1104
+ }
1105
+ });
1106
+ tx(samples);
1107
+ }
1108
+ radioSeries(device, sinceTs) {
1109
+ const rows = this.db.query(`SELECT radio_id AS radioId, cap, band, ts, clients, channel
1110
+ FROM capsman_samples WHERE device=$d AND ts>=$since ORDER BY ts ASC`).all({ $d: device, $since: sinceTs });
1111
+ return summariseRadioSamples(rows);
1112
+ }
1113
+ pruneSamples(olderThanTs) {
1114
+ const res = this.db.query("DELETE FROM capsman_samples WHERE ts < $t").run({ $t: olderThanTs });
1115
+ return Number(res.changes ?? 0);
1116
+ }
1117
+ close() {
1118
+ this.db.close();
1119
+ }
1120
+ }
1121
+ async function openCapsmanStore(path) {
1122
+ if (path !== ":memory:") {
1123
+ try {
1124
+ mkdirSync4(dirname3(path), { recursive: true });
1125
+ } catch {}
1126
+ }
1127
+ const { Database } = await import("bun:sqlite");
1128
+ const db = new Database(path, { create: true });
1129
+ return new SqliteCapsmanStore(db);
1130
+ }
1131
+
1132
+ // src/observability/capsman-sampler.ts
1133
+ var SERVER_TAG = "mikrotik-mcp";
1134
+ var CAPSMAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1135
+ var DEFAULT_CAPSMAN_INTERVAL_MS = 5 * 60000;
1136
+ var MIN_CAPSMAN_INTERVAL_MS = 60000;
1137
+ var MAX_CAPSMAN_INTERVAL_MS = 6 * 60 * 60000;
1138
+ var timer2 = null;
1139
+ var inFlight2 = false;
1140
+ var noCapsman = new Set;
1141
+ function clampInterval(ms) {
1142
+ if (!Number.isFinite(ms))
1143
+ return DEFAULT_CAPSMAN_INTERVAL_MS;
1144
+ return Math.max(MIN_CAPSMAN_INTERVAL_MS, Math.min(MAX_CAPSMAN_INTERVAL_MS, Math.round(ms)));
1145
+ }
1146
+ async function sampleDevice(store, device, ts) {
1147
+ if (noCapsman.has(device))
1148
+ return;
1149
+ const ctx = createContext(undefined, device);
1150
+ const state = await fetchCapsmanState(ctx);
1151
+ if (state.path === null) {
1152
+ noCapsman.add(device);
1153
+ return;
1154
+ }
1155
+ const overview = capsmanOverview(state);
1156
+ const samples = overview.radios.map((r) => ({
1157
+ radioId: r.radioId,
1158
+ cap: r.cap,
1159
+ band: r.band,
1160
+ channel: r.channel,
1161
+ clients: r.clientCount
1162
+ }));
1163
+ store.recordRadioSamples(device, ts, samples);
1164
+ }
1165
+ async function sampleCapsmanOnce(store) {
1166
+ if (inFlight2)
1167
+ return;
1168
+ inFlight2 = true;
1169
+ const ts = Date.now();
1170
+ try {
1171
+ const cfg = getConfig();
1172
+ await Promise.all(Object.entries(cfg.devices).map(async ([name, dc]) => {
1173
+ if (dc.mac)
1174
+ return;
1175
+ try {
1176
+ await sampleDevice(store, name, ts);
1177
+ } catch (e) {
1178
+ logger.warn(`[${SERVER_TAG}] capsman sample failed for '${name}': ${String(e)}`);
1179
+ }
1180
+ }));
1181
+ store.pruneSamples(ts - CAPSMAN_RETENTION_MS);
1182
+ } finally {
1183
+ inFlight2 = false;
1184
+ }
1185
+ }
1186
+ function startCapsmanSampler(store, intervalMs = DEFAULT_CAPSMAN_INTERVAL_MS) {
1187
+ const ms = clampInterval(intervalMs);
1188
+ sampleCapsmanOnce(store);
1189
+ timer2 = setInterval(() => void sampleCapsmanOnce(store), ms);
1190
+ }
1191
+ function stopCapsmanSampler() {
1192
+ if (timer2) {
1193
+ clearInterval(timer2);
1194
+ timer2 = null;
1195
+ }
1196
+ }
1197
+
1198
+ // src/observability/usage-store.ts
1199
+ import { mkdirSync as mkdirSync5 } from "fs";
1200
+ import { dirname as dirname4 } from "path";
1024
1201
  function dayOf(ts) {
1025
1202
  return new Date(ts).toISOString().slice(0, 10);
1026
1203
  }
@@ -1039,7 +1216,7 @@ function dailyUsageFromSamples(samples) {
1039
1216
  }
1040
1217
  return [...byDay.entries()].map(([day, v]) => ({ day, rx: v.rx, tx: v.tx })).sort((a, b) => a.day.localeCompare(b.day));
1041
1218
  }
1042
- var SCHEMA_STATEMENTS2 = [
1219
+ var SCHEMA_STATEMENTS3 = [
1043
1220
  `CREATE TABLE IF NOT EXISTS usage_samples (
1044
1221
  device TEXT NOT NULL,
1045
1222
  subject TEXT NOT NULL,
@@ -1071,7 +1248,7 @@ class SqliteUsageStore {
1071
1248
  this.db = db;
1072
1249
  db.run("PRAGMA journal_mode = WAL");
1073
1250
  db.run("PRAGMA synchronous = NORMAL");
1074
- for (const stmt of SCHEMA_STATEMENTS2)
1251
+ for (const stmt of SCHEMA_STATEMENTS3)
1075
1252
  db.run(stmt);
1076
1253
  }
1077
1254
  recordClientSamples(device, ts, samples) {
@@ -1140,7 +1317,7 @@ class SqliteUsageStore {
1140
1317
  async function openUsageStore(path) {
1141
1318
  if (path !== ":memory:") {
1142
1319
  try {
1143
- mkdirSync4(dirname3(path), { recursive: true });
1320
+ mkdirSync5(dirname4(path), { recursive: true });
1144
1321
  } catch {}
1145
1322
  }
1146
1323
  const { Database } = await import("bun:sqlite");
@@ -1149,16 +1326,16 @@ async function openUsageStore(path) {
1149
1326
  }
1150
1327
 
1151
1328
  // src/observability/usage-sampler.ts
1152
- var SERVER_TAG = "mikrotik-mcp";
1329
+ var SERVER_TAG2 = "mikrotik-mcp";
1153
1330
  var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
1154
1331
  var DEFAULT_USAGE_INTERVAL_MS = 60000;
1155
1332
  var MIN_USAGE_INTERVAL_MS = 30000;
1156
1333
  var MAX_USAGE_INTERVAL_MS = 6 * 60 * 60000;
1157
- var timer2 = null;
1158
- var inFlight2 = false;
1334
+ var timer3 = null;
1335
+ var inFlight3 = false;
1159
1336
  var currentStore = null;
1160
1337
  var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
1161
- function clampInterval(ms) {
1338
+ function clampInterval2(ms) {
1162
1339
  if (!Number.isFinite(ms))
1163
1340
  return DEFAULT_USAGE_INTERVAL_MS;
1164
1341
  return Math.max(MIN_USAGE_INTERVAL_MS, Math.min(MAX_USAGE_INTERVAL_MS, Math.round(ms)));
@@ -1184,10 +1361,17 @@ async function sampleClients(store, device, ts) {
1184
1361
  }
1185
1362
  store.recordClientSamples(device, ts, samples);
1186
1363
  }
1364
+ var noUserManager = new Set;
1187
1365
  async function ingestSessions(store, device) {
1366
+ if (noUserManager.has(device))
1367
+ return;
1188
1368
  const ctx = createContext(undefined, device);
1189
1369
  const out = await executeMikrotikCommand("/user-manager session print detail", ctx);
1190
- if (isEmpty(out) || looksLikeError(out) || commandUnsupported(out))
1370
+ if (commandUnsupported(out)) {
1371
+ noUserManager.add(device);
1372
+ return;
1373
+ }
1374
+ if (isEmpty(out) || looksLikeError(out))
1191
1375
  return;
1192
1376
  const sessions = [];
1193
1377
  for (const row of parseRecords(out).rows) {
@@ -1209,9 +1393,9 @@ async function ingestSessions(store, device) {
1209
1393
  store.upsertSessions(device, sessions);
1210
1394
  }
1211
1395
  async function sampleUsageOnce(store) {
1212
- if (inFlight2)
1396
+ if (inFlight3)
1213
1397
  return;
1214
- inFlight2 = true;
1398
+ inFlight3 = true;
1215
1399
  const ts = Date.now();
1216
1400
  try {
1217
1401
  const cfg = getConfig();
@@ -1222,37 +1406,37 @@ async function sampleUsageOnce(store) {
1222
1406
  await sampleClients(store, name, ts);
1223
1407
  await ingestSessions(store, name);
1224
1408
  } catch (e) {
1225
- logger.warn(`[${SERVER_TAG}] usage sample failed for '${name}': ${String(e)}`);
1409
+ logger.warn(`[${SERVER_TAG2}] usage sample failed for '${name}': ${String(e)}`);
1226
1410
  }
1227
1411
  }));
1228
1412
  store.pruneSamples(ts - USAGE_RETENTION_MS);
1229
1413
  } finally {
1230
- inFlight2 = false;
1414
+ inFlight3 = false;
1231
1415
  }
1232
1416
  }
1233
1417
  function startUsageSampler(store, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
1234
1418
  currentStore = store;
1235
- currentIntervalMs = clampInterval(intervalMs);
1419
+ currentIntervalMs = clampInterval2(intervalMs);
1236
1420
  sampleUsageOnce(store);
1237
- timer2 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1421
+ timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1238
1422
  }
1239
1423
  function getUsageSamplerInterval() {
1240
1424
  return currentIntervalMs;
1241
1425
  }
1242
1426
  function setUsageSamplerInterval(intervalMs) {
1243
- currentIntervalMs = clampInterval(intervalMs);
1244
- if (timer2)
1245
- clearInterval(timer2);
1427
+ currentIntervalMs = clampInterval2(intervalMs);
1428
+ if (timer3)
1429
+ clearInterval(timer3);
1246
1430
  if (currentStore) {
1247
1431
  const store = currentStore;
1248
- timer2 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1432
+ timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1249
1433
  }
1250
1434
  return currentIntervalMs;
1251
1435
  }
1252
1436
  function stopUsageSampler() {
1253
- if (timer2) {
1254
- clearInterval(timer2);
1255
- timer2 = null;
1437
+ if (timer3) {
1438
+ clearInterval(timer3);
1439
+ timer3 = null;
1256
1440
  }
1257
1441
  currentStore = null;
1258
1442
  }
@@ -1614,7 +1798,7 @@ async function memoryRoutes(req, url) {
1614
1798
  }
1615
1799
 
1616
1800
  // src/observability/dashboard.ts
1617
- var SERVER_TAG2 = "mikrotik-mcp";
1801
+ var SERVER_TAG3 = "mikrotik-mcp";
1618
1802
  var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
1619
1803
  function json3(body, status = 200) {
1620
1804
  return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS3 });
@@ -1645,7 +1829,7 @@ function runUpgrade(spec) {
1645
1829
  env: process.env,
1646
1830
  stdio: ["ignore", "pipe", "pipe"]
1647
1831
  });
1648
- const timer3 = setTimeout(() => {
1832
+ const timer4 = setTimeout(() => {
1649
1833
  child.kill();
1650
1834
  resolve({ ok: false, log: `${out}
1651
1835
  (timed out after 180s)` });
@@ -1653,12 +1837,12 @@ function runUpgrade(spec) {
1653
1837
  child.stdout?.on("data", (d) => out += d.toString());
1654
1838
  child.stderr?.on("data", (d) => out += d.toString());
1655
1839
  child.on("error", (e) => {
1656
- clearTimeout(timer3);
1840
+ clearTimeout(timer4);
1657
1841
  resolve({ ok: false, log: `${out}
1658
1842
  spawn error: ${String(e)}` });
1659
1843
  });
1660
1844
  child.on("exit", (code) => {
1661
- clearTimeout(timer3);
1845
+ clearTimeout(timer4);
1662
1846
  resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
1663
1847
  });
1664
1848
  });
@@ -1775,7 +1959,7 @@ function devicesPayload(store) {
1775
1959
  }
1776
1960
  };
1777
1961
  });
1778
- return { server: SERVER_TAG2, defaultDevice: cfg.defaultDevice, devices };
1962
+ return { server: SERVER_TAG3, defaultDevice: cfg.defaultDevice, devices };
1779
1963
  }
1780
1964
  function topologyPayload() {
1781
1965
  const cfg = getConfig();
@@ -1788,7 +1972,7 @@ function topologyPayload() {
1788
1972
  for (const { name } of devices)
1789
1973
  neighborsByDevice[name] = getDeviceNeighbors(name);
1790
1974
  return {
1791
- server: SERVER_TAG2,
1975
+ server: SERVER_TAG3,
1792
1976
  defaultDevice: cfg.defaultDevice,
1793
1977
  generatedAt: Date.now(),
1794
1978
  ...buildTopology({ devices, neighborsByDevice })
@@ -2171,6 +2355,99 @@ async function captureRoutes(req, url) {
2171
2355
  }
2172
2356
  return null;
2173
2357
  }
2358
+ var capsmanStore = null;
2359
+ async function capsmanRoutes(req, url) {
2360
+ const p = url.pathname;
2361
+ if (!p.startsWith("/api/capsman"))
2362
+ return null;
2363
+ if (req.method === "GET") {
2364
+ const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
2365
+ if (p === "/api/capsman/overview") {
2366
+ return json3(capsmanOverview(await fetchCapsmanState(ctx)));
2367
+ }
2368
+ if (p === "/api/capsman/clients") {
2369
+ const state = await fetchCapsmanState(ctx);
2370
+ const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
2371
+ const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
2372
+ return json3({ clients: state.clients, weak });
2373
+ }
2374
+ if (p === "/api/capsman/audit") {
2375
+ return json3(runCapsmanAudit(await fetchCapsmanState(ctx)));
2376
+ }
2377
+ if (p === "/api/capsman/trends") {
2378
+ if (!capsmanStore)
2379
+ return json3({ error: "capsman store not active" }, 503);
2380
+ const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2381
+ const days = daysParam(url, 7, 30);
2382
+ const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
2383
+ return json3({ series, days });
2384
+ }
2385
+ return null;
2386
+ }
2387
+ if (req.method === "POST") {
2388
+ const body = await readJson(req);
2389
+ const ctx = createContext(undefined, body?.device);
2390
+ const state = await fetchCapsmanState(ctx);
2391
+ if (p === "/api/capsman/apply/steer") {
2392
+ const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
2393
+ if (!client)
2394
+ return json3({ ok: false, error: "client not associated" }, 400);
2395
+ if (steerAlreadyPresent(state, body.mac ?? "")) {
2396
+ return json3({ ok: true, message: "already steered (no-op)" });
2397
+ }
2398
+ const mode = body.mode === "soft" ? "soft" : "hard";
2399
+ const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
2400
+ if (!body.confirm)
2401
+ return json3({ ok: true, preview: commands });
2402
+ return json3(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
2403
+ }
2404
+ if (p === "/api/capsman/apply/load-balance") {
2405
+ const plan = loadBalancePlan(state);
2406
+ const commands = buildLoadBalanceCommands(state, plan);
2407
+ if (!body.confirm)
2408
+ return json3({ ok: true, preview: commands, plan });
2409
+ return json3(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
2410
+ }
2411
+ if (p === "/api/capsman/apply/channel-plan") {
2412
+ if (state.path === "/caps-man") {
2413
+ return json3({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
2414
+ }
2415
+ const commands = buildChannelPlanCommands(state);
2416
+ if (!body.confirm)
2417
+ return json3({ ok: true, preview: commands });
2418
+ return json3(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
2419
+ }
2420
+ if (p === "/api/capsman/apply/ft") {
2421
+ const commands = buildFtCommands(state);
2422
+ if (!body.confirm)
2423
+ return json3({ ok: true, preview: commands });
2424
+ return json3(await applyCapsmanWrites(ctx, commands, "pre-ft"));
2425
+ }
2426
+ if (p === "/api/capsman/apply/ha") {
2427
+ const commands = buildHaCommands(state);
2428
+ const guidance = haGuidance(state);
2429
+ if (!body.confirm)
2430
+ return json3({ ok: true, preview: commands, guidance });
2431
+ const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
2432
+ return json3({ ...res, guidance });
2433
+ }
2434
+ }
2435
+ return null;
2436
+ }
2437
+ async function applyCapsmanWrites(ctx, commands, label) {
2438
+ if (commands.length === 0)
2439
+ return { ok: true, applied: 0 };
2440
+ const snapshotId = await captureSnapshot(ctx, label);
2441
+ const device = resolveDeviceName(ctx.device);
2442
+ const outcome = await applyWritesSafely(ctx, device, commands, { allowDirectFallback: false });
2443
+ return {
2444
+ ok: outcome.committed && !outcome.error,
2445
+ snapshotId,
2446
+ safeMode: outcome.safeMode,
2447
+ applied: outcome.applied,
2448
+ error: outcome.error
2449
+ };
2450
+ }
2174
2451
  async function clientsRoutes(req, url) {
2175
2452
  const p = url.pathname;
2176
2453
  if (!p.startsWith("/api/clients"))
@@ -2559,16 +2836,22 @@ async function runDashboard(cfg, transportLabel) {
2559
2836
  });
2560
2837
  startHealthChecks(30000);
2561
2838
  try {
2562
- usageStore = await openUsageStore(join3(dirname4(cfg.dbPath), "usage.db"));
2839
+ usageStore = await openUsageStore(join3(dirname5(cfg.dbPath), "usage.db"));
2563
2840
  startUsageSampler(usageStore);
2564
2841
  } catch (e) {
2565
- logger.warn(`[${SERVER_TAG2}] usage history disabled: ${String(e)}`);
2842
+ logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
2843
+ }
2844
+ try {
2845
+ capsmanStore = await openCapsmanStore(join3(dirname5(cfg.dbPath), "capsman.db"));
2846
+ startCapsmanSampler(capsmanStore);
2847
+ } catch (e) {
2848
+ logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
2566
2849
  }
2567
2850
  try {
2568
2851
  if (isEmpty2())
2569
2852
  recordVersion(getConfig(), "auto", Date.now(), "baseline");
2570
2853
  } catch (e) {
2571
- logger.warn(`[${SERVER_TAG2}] could not seed config history baseline: ${String(e)}`);
2854
+ logger.warn(`[${SERVER_TAG3}] could not seed config history baseline: ${String(e)}`);
2572
2855
  }
2573
2856
  const configAdmin = createConfigAdmin({
2574
2857
  getConfig,
@@ -2615,6 +2898,9 @@ async function runDashboard(cfg, transportLabel) {
2615
2898
  const captureResp = await captureRoutes(req, url);
2616
2899
  if (captureResp)
2617
2900
  return captureResp;
2901
+ const capsmanResp = await capsmanRoutes(req, url);
2902
+ if (capsmanResp)
2903
+ return capsmanResp;
2618
2904
  const clientsResp = await clientsRoutes(req, url);
2619
2905
  if (clientsResp)
2620
2906
  return clientsResp;
@@ -2868,10 +3154,13 @@ async function runDashboard(cfg, transportLabel) {
2868
3154
  stop() {
2869
3155
  stopHealthChecks();
2870
3156
  stopUsageSampler();
3157
+ stopCapsmanSampler();
2871
3158
  server.stop(true);
2872
3159
  store.close();
2873
3160
  usageStore?.close();
2874
3161
  usageStore = null;
3162
+ capsmanStore?.close();
3163
+ capsmanStore = null;
2875
3164
  closeMemoryStore();
2876
3165
  }
2877
3166
  };
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-qma8metz.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-v75jtv6p.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,