@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 +1 -0
- package/dist/cli.js +318 -32
- package/dist/index.js +1 -1
- package/dist/shared/{cli-9s02dq5d.js → cli-7jxnfmp0.js} +1 -1
- package/dist/shared/{cli-w6ecrhxa.js → cli-dv2kmsh2.js} +1057 -26
- package/dist/shared/{library-c0fxxq85.js → library-77r1j24d.js} +1 -1
- package/dist/shared/{library-212xbkwr.js → library-7m2c1g1y.js} +990 -25
- package/dist/ui/observability.html +58 -58
- package/package.json +1 -1
- package/schemas/README.md +1 -1
- package/schemas/tool-catalog.json +321 -2
- package/schemas/tools/apply_capsman_channel_plan.json +20 -0
- package/schemas/tools/apply_capsman_fixes.json +24 -0
- package/schemas/tools/apply_capsman_load_balance.json +13 -0
- package/schemas/tools/audit_capsman_coverage.json +7 -0
- package/schemas/tools/audit_capsman_ft.json +7 -0
- package/schemas/tools/audit_capsman_ha.json +7 -0
- package/schemas/tools/audit_capsman_load.json +7 -0
- package/schemas/tools/enable_capsman_ft.json +24 -0
- package/schemas/tools/report_weak_signal_clients.json +14 -0
- package/schemas/tools/run_capsman_audit.json +28 -0
- package/schemas/tools/setup_capsman_ha.json +17 -0
- package/schemas/tools/steer_client.json +37 -0
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-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1158
|
-
var
|
|
1338
|
+
var timer3 = null;
|
|
1339
|
+
var inFlight3 = false;
|
|
1159
1340
|
var currentStore = null;
|
|
1160
1341
|
var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
|
|
1161
|
-
function
|
|
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 (
|
|
1400
|
+
if (inFlight3)
|
|
1220
1401
|
return;
|
|
1221
|
-
|
|
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(`[${
|
|
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
|
-
|
|
1418
|
+
inFlight3 = false;
|
|
1238
1419
|
}
|
|
1239
1420
|
}
|
|
1240
1421
|
function startUsageSampler(store, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
|
|
1241
1422
|
currentStore = store;
|
|
1242
|
-
currentIntervalMs =
|
|
1423
|
+
currentIntervalMs = clampInterval2(intervalMs);
|
|
1243
1424
|
sampleUsageOnce(store);
|
|
1244
|
-
|
|
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 =
|
|
1251
|
-
if (
|
|
1252
|
-
clearInterval(
|
|
1431
|
+
currentIntervalMs = clampInterval2(intervalMs);
|
|
1432
|
+
if (timer3)
|
|
1433
|
+
clearInterval(timer3);
|
|
1253
1434
|
if (currentStore) {
|
|
1254
1435
|
const store = currentStore;
|
|
1255
|
-
|
|
1436
|
+
timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
|
|
1256
1437
|
}
|
|
1257
1438
|
return currentIntervalMs;
|
|
1258
1439
|
}
|
|
1259
1440
|
function stopUsageSampler() {
|
|
1260
|
-
if (
|
|
1261
|
-
clearInterval(
|
|
1262
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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(
|
|
2843
|
+
usageStore = await openUsageStore(join3(dirname5(cfg.dbPath), "usage.db"));
|
|
2570
2844
|
startUsageSampler(usageStore);
|
|
2571
2845
|
} catch (e) {
|
|
2572
|
-
logger.warn(`[${
|
|
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(`[${
|
|
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-
|
|
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";
|