@usex/mikrotik-mcp 4.24.0 → 5.1.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.
Files changed (74) hide show
  1. package/README.md +108 -51
  2. package/dist/cli.js +1854 -554
  3. package/dist/index.d.ts +57 -0
  4. package/dist/index.js +1 -1
  5. package/dist/shared/{library-2f8ty72a.js → cli-mqb3rqfy.js} +34841 -22974
  6. package/dist/shared/{cli-ps0h2mxx.js → cli-s9fc4c7j.js} +1 -1
  7. package/dist/shared/{cli-10wtq3xb.js → library-tq8phch8.js} +27682 -16779
  8. package/dist/shared/{library-phwm9kqe.js → library-xgy70fsf.js} +1 -1
  9. package/dist/ui/observability.html +71 -62
  10. package/package.json +3 -2
  11. package/policies/baseline.yaml +180 -0
  12. package/prompts/backup-and-document.md +28 -18
  13. package/prompts/build-tunnel-transactionally.md +74 -0
  14. package/prompts/fleet-rollout.md +68 -0
  15. package/prompts/setup-traffic-flow.md +68 -0
  16. package/schemas/README.md +1 -1
  17. package/schemas/config.schema.json +367 -4
  18. package/schemas/tool-catalog.json +3050 -1152
  19. package/schemas/tools/abort_rollout.json +17 -0
  20. package/schemas/tools/abort_transaction.json +17 -0
  21. package/schemas/tools/add_alert_rule.json +45 -0
  22. package/schemas/tools/add_ipv6_route.json +1 -1
  23. package/schemas/tools/add_route.json +3 -2
  24. package/schemas/tools/add_schedule.json +50 -0
  25. package/schemas/tools/add_script.json +1 -1
  26. package/schemas/tools/add_traffic_flow_target.json +36 -0
  27. package/schemas/tools/add_transaction_step.json +21 -0
  28. package/schemas/tools/analyze_flows.json +25 -0
  29. package/schemas/tools/begin_transaction.json +109 -0
  30. package/schemas/tools/block_attacker.json +22 -0
  31. package/schemas/tools/check_policy_snapshot.json +20 -0
  32. package/schemas/tools/commit_transaction.json +13 -0
  33. package/schemas/tools/configure_attack_response.json +12 -0
  34. package/schemas/tools/diff_explanations.json +24 -0
  35. package/schemas/tools/explain_device.json +22 -0
  36. package/schemas/tools/explain_policy_finding.json +17 -0
  37. package/schemas/tools/explain_rule_reachability.json +18 -0
  38. package/schemas/tools/explain_section.json +32 -0
  39. package/schemas/tools/export_policy_report.json +22 -0
  40. package/schemas/tools/flow_top_talkers.json +31 -0
  41. package/schemas/tools/get_alert_history.json +26 -0
  42. package/schemas/tools/get_attack_incident.json +13 -0
  43. package/schemas/tools/get_audit_timeline.json +24 -0
  44. package/schemas/tools/get_device_capabilities.json +7 -0
  45. package/schemas/tools/get_traffic_flow_settings.json +7 -0
  46. package/schemas/tools/list_alert_rules.json +7 -0
  47. package/schemas/tools/list_attack_incidents.json +27 -0
  48. package/schemas/tools/list_attack_responses.json +20 -0
  49. package/schemas/tools/list_policies.json +17 -0
  50. package/schemas/tools/list_schedules.json +12 -0
  51. package/schemas/tools/list_traffic_flow_targets.json +7 -0
  52. package/schemas/tools/mute_alert_rule.json +18 -0
  53. package/schemas/tools/plan_rollout.json +95 -0
  54. package/schemas/tools/refresh_device_capabilities.json +7 -0
  55. package/schemas/tools/remove_alert_rule.json +13 -0
  56. package/schemas/tools/remove_schedule.json +13 -0
  57. package/schemas/tools/remove_traffic_flow_target.json +13 -0
  58. package/schemas/tools/rollout_status.json +12 -0
  59. package/schemas/tools/run_policy_check.json +12 -0
  60. package/schemas/tools/run_schedule_now.json +13 -0
  61. package/schemas/tools/scan_for_attacks.json +27 -0
  62. package/schemas/tools/set_traffic_flow_settings.json +28 -0
  63. package/schemas/tools/simulate_change.json +72 -0
  64. package/schemas/tools/simulate_packet.json +53 -0
  65. package/schemas/tools/simulate_suite.json +71 -0
  66. package/schemas/tools/start_flow_collector.json +14 -0
  67. package/schemas/tools/start_rollout.json +98 -0
  68. package/schemas/tools/stop_flow_collector.json +7 -0
  69. package/schemas/tools/test_alert_channel.json +14 -0
  70. package/schemas/tools/unblock_attacker.json +20 -0
  71. package/schemas/tools/update_alert_rule.json +41 -0
  72. package/schemas/tools/update_route.json +3 -2
  73. package/schemas/tools/validate_policy_file.json +16 -0
  74. package/schemas/tools/verify_transaction.json +13 -0
package/dist/cli.js CHANGED
@@ -3,13 +3,17 @@
3
3
  import {
4
4
  AAA_ENTITIES,
5
5
  ALWAYS_ON_MODULES,
6
+ AlertEngine,
7
+ AlertRuleSchema,
6
8
  Cmd,
7
9
  DEFAULT_CONFIG_HISTORY_DIR,
8
10
  DEFAULT_SNAPSHOT_DB,
9
11
  DEFAULT_TZSP_PORT,
10
12
  DeviceConfigSchema,
13
+ JobSchema,
11
14
  LOGO_URL,
12
15
  MikrotikConfigSchema,
16
+ NARRATIVE_SECTIONS,
13
17
  PKG_META,
14
18
  PROJECT_ROOT,
15
19
  PROMPTS_DIR,
@@ -25,17 +29,24 @@ import {
25
29
  addAaaEntity,
26
30
  allToolModules,
27
31
  allowDevice,
32
+ analyzeDevice,
28
33
  analyzeDrift,
29
34
  applyWritesSafely,
35
+ armJob,
36
+ attackStore,
30
37
  attributeChanges,
38
+ auditAdapter,
31
39
  backupDir,
32
40
  blockDevice,
33
41
  buildChangePlan,
34
42
  buildChannelPlanCommands,
35
43
  buildFtCommands,
44
+ buildGuards,
36
45
  buildHaCommands,
37
46
  buildLoadBalanceCommands,
47
+ buildModel,
38
48
  buildSteerCommands,
49
+ buildTopology,
39
50
  capsmanOverview,
40
51
  capture,
41
52
  captureSnapshot,
@@ -45,35 +56,73 @@ import {
45
56
  closeMemoryStore,
46
57
  commandUnsupported,
47
58
  configureRecorder,
59
+ conversations,
48
60
  createContext,
49
61
  createDeviceClient,
62
+ createDeviceExecutor,
63
+ createDeviceExecutor1 as createDeviceExecutor2,
50
64
  createLocalBackup,
65
+ currentPolicySet,
66
+ decide,
51
67
  deleteBackup,
68
+ deliver,
69
+ describeCron,
70
+ describeDiff,
52
71
  describeTransport,
53
72
  deviceDirectory,
54
73
  deviceLabels,
55
74
  devicesView,
75
+ diffFindings,
56
76
  diffLines,
77
+ diffNarratives,
78
+ diffTraces,
79
+ dropRollout,
80
+ dropTxn,
81
+ evaluatePolicies,
82
+ eventMet,
57
83
  executeMikrotikCommand,
84
+ executePlan,
85
+ explainUnmet,
58
86
  fetchAllReleases,
59
87
  fetchCapsmanState,
60
88
  fetchDevices,
61
89
  fetchLatestRelease,
90
+ flowStore,
91
+ forgetJob,
92
+ getAlertEngine,
93
+ getCapabilities,
62
94
  getConfig,
63
95
  getConfigSource,
96
+ getDeviceHistory,
97
+ getDeviceNeighbors,
98
+ getDeviceStatus,
64
99
  getEventStore,
100
+ getFlowCollector,
65
101
  getMemoryStore,
66
102
  getRadiusIncoming,
103
+ getRollout,
67
104
  getS3Client,
105
+ getTxn,
68
106
  getUmSettings,
69
107
  haGuidance,
108
+ initialState,
109
+ invalidateCapabilities,
70
110
  isEmpty,
111
+ isEventTrigger,
71
112
  isMacTelnetDevice,
113
+ isMuted,
114
+ isNeverBlock,
115
+ isPlan,
72
116
  isPoolEnabled,
73
117
  isS3Configured,
74
118
  listAaaEntity,
75
119
  listBackups,
76
120
  listDevices,
121
+ listRollouts,
122
+ listTxns,
123
+ liveExecutor,
124
+ liveRolloutUpdates,
125
+ liveTxnUpdates,
77
126
  loadBalancePlan,
78
127
  loadConfig,
79
128
  loadFileCacheSync,
@@ -81,51 +130,107 @@ import {
81
130
  looksLikeError,
82
131
  makeDeviceStatic,
83
132
  moduleCatalog,
133
+ neverBlockSet,
134
+ nextRun,
135
+ nextRunOf,
84
136
  normalizeExport,
137
+ noteDriftResult,
85
138
  openSnapshotStore,
86
- parseDisks,
87
- parseKeyValues,
139
+ parseDuration,
140
+ parseExport,
88
141
  parseLeadingNumber,
89
142
  parseRecords,
90
143
  parseRouterosDate,
144
+ parseRules,
91
145
  parseSize,
92
- parseSystemResource,
146
+ peekCapabilities,
147
+ persistRollout,
148
+ persistTxn,
149
+ policyFromConfig,
93
150
  poolStatus,
94
151
  presignExpiresIn,
152
+ primeRiskIndex,
153
+ probeDevice,
154
+ protocolMix,
155
+ publishRollout,
95
156
  readBackup,
96
157
  redact,
158
+ redactChannels,
97
159
  registerTools,
98
160
  registerUiResources,
161
+ rememberReport,
99
162
  removeAaaEntity,
100
163
  removeDeviceLease,
101
164
  renameBackup,
165
+ renderDiff,
166
+ renderNarrative,
102
167
  renderPlan,
168
+ renderReport,
103
169
  reopenMemoryStore,
104
170
  reportWeakClients,
171
+ requestAbort,
172
+ requestAbort1 as requestAbort2,
173
+ requestHold,
105
174
  resetRadiusCounters,
106
175
  resolveDeviceName,
107
176
  restoreLocalBackup,
177
+ resume,
178
+ revokeBlock,
108
179
  riskOf,
180
+ rolloutStore,
181
+ ruleWarnings,
109
182
  runCapsmanAudit,
183
+ runJob,
184
+ runRollout,
185
+ runTransaction,
186
+ runnerOptions,
110
187
  s3Target,
111
188
  sampleAllTraffic,
112
189
  sampleDeviceTraffic,
190
+ schedulableTools,
191
+ scheduleStore,
113
192
  selectToolModules,
193
+ serializeCapabilities,
194
+ setAlertEngine,
114
195
  setConfig,
115
196
  setDeviceIp,
116
197
  setDeviceLabel,
117
198
  setDeviceLimits,
199
+ setMcpAlertSender,
118
200
  setRadiusIncoming,
119
201
  setUmSettings,
202
+ severityCounts,
120
203
  splitCommands,
204
+ startAttackDetection,
205
+ startHealthChecks,
206
+ startScheduler,
121
207
  steerAlreadyPresent,
208
+ step,
209
+ stopAttackDetection,
210
+ stopHealthChecks,
211
+ stopScheduler,
122
212
  subscribe,
213
+ subscribeRollout,
214
+ subscribeTxn,
123
215
  subscriberCount,
216
+ summarize,
217
+ summarize1 as summarize2,
218
+ sweep,
219
+ timeline,
220
+ toRecord,
221
+ toRecord1 as toRecord2,
124
222
  toggleAaaEntity,
223
+ topTalkers,
224
+ topologyMermaid,
225
+ tracePacket,
226
+ txnStore,
227
+ unreachableRules,
125
228
  updateAaaEntity,
126
229
  updateSummaryLine,
230
+ validatePolicyText,
231
+ worstSeverity,
127
232
  writeBackup
128
- } from "./shared/cli-10wtq3xb.js";
233
+ } from "./shared/cli-mqb3rqfy.js";
129
234
 
130
235
  // src/cli.ts
131
236
  import { existsSync as existsSync2 } from "fs";
@@ -134,11 +239,23 @@ import { existsSync as existsSync2 } from "fs";
134
239
  import { spawn } from "child_process";
135
240
  import { readFileSync as readFileSync3 } from "fs";
136
241
  import { homedir as homedir3, networkInterfaces } from "os";
137
- import { dirname as dirname5, join as join4 } from "path";
242
+ import { dirname as dirname7, join as join4 } from "path";
138
243
  var {serve } = globalThis.Bun;
139
244
  import { z as z2 } from "zod";
140
245
 
141
246
  // src/observability/modules.ts
247
+ function unsupportedDevices(requires) {
248
+ const out = [];
249
+ for (const name of Object.keys(getConfig().devices)) {
250
+ const caps = peekCapabilities(name);
251
+ if (!caps)
252
+ continue;
253
+ const reason = explainUnmet(caps, requires);
254
+ if (reason)
255
+ out.push({ device: name, reason });
256
+ }
257
+ return out;
258
+ }
142
259
  var lcSet = (xs) => new Set((xs ?? []).map((s) => s.toLowerCase()));
143
260
  function isModuleEnabled(filter, slug, group) {
144
261
  const enabledModules = lcSet(filter.enabledModules);
@@ -157,14 +274,19 @@ function isModuleEnabled(filter, slug, group) {
157
274
  return true;
158
275
  }
159
276
  function moduleSurface(filter, catalog = moduleCatalog) {
160
- const modules = catalog.map((m) => ({
161
- slug: m.slug,
162
- label: m.label,
163
- group: m.group,
164
- description: m.description,
165
- toolCount: m.tools.length,
166
- enabled: isModuleEnabled(filter, m.slug, m.group)
167
- }));
277
+ const modules = catalog.map((m) => {
278
+ const requires = m.tools.find((t) => t.requires)?.requires;
279
+ const unsupported = requires ? unsupportedDevices(requires) : [];
280
+ return {
281
+ slug: m.slug,
282
+ label: m.label,
283
+ group: m.group,
284
+ description: m.description,
285
+ toolCount: m.tools.length,
286
+ enabled: isModuleEnabled(filter, m.slug, m.group),
287
+ ...unsupported.length > 0 ? { unsupported } : {}
288
+ };
289
+ });
168
290
  const hasAllowList = (filter.enabledModules?.length ?? 0) > 0 || (filter.enabledGroups?.length ?? 0) > 0;
169
291
  return {
170
292
  modules,
@@ -532,297 +654,13 @@ function registerPrompts(server, opts = {}) {
532
654
  return count;
533
655
  }
534
656
 
535
- // src/observability/topology.ts
536
- function normMac(mac) {
537
- if (!mac)
538
- return;
539
- const hex = mac.replace(/[^0-9a-fA-F]/g, "").toUpperCase();
540
- return hex.length ? hex : undefined;
541
- }
542
- function parseNeighbors(text) {
543
- const { rows } = parseRecords(text);
544
- const out = [];
545
- for (const r of rows) {
546
- const n = {
547
- interface: r.interface || r["interface-name"] || undefined,
548
- ip: r.address || r.address4 || undefined,
549
- ipv6: r.address6 || undefined,
550
- mac: r["mac-address"] || undefined,
551
- identity: r.identity || undefined,
552
- platform: r.platform || undefined,
553
- board: r.board || undefined,
554
- version: r.version || undefined
555
- };
556
- if (n.mac || n.ip || n.identity)
557
- out.push(n);
558
- }
559
- return out;
560
- }
561
- function suggestName(n, taken) {
562
- const base = n.identity && n.identity.trim().replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || (normMac(n.mac) ? `device-${normMac(n.mac).slice(-6).toLowerCase()}` : "") || (n.ip ? `device-${n.ip.replace(/\./g, "-")}` : "device");
563
- let name = base || "device";
564
- let i = 2;
565
- while (taken.has(name))
566
- name = `${base}-${i++}`;
567
- taken.add(name);
568
- return name;
569
- }
570
- function buildTopology(input) {
571
- const macToDevice = new Map;
572
- const ipToDevice = new Map;
573
- const identityToDevice = new Map;
574
- const nodes = new Map;
575
- for (const { name, config, status } of input.devices) {
576
- nodes.set(name, {
577
- id: name,
578
- kind: "device",
579
- label: status.identity || name,
580
- configured: true,
581
- onboardable: false,
582
- identity: status.identity,
583
- ip: config.host,
584
- mac: config.mac,
585
- platform: "MikroTik",
586
- board: status.boardName,
587
- version: status.version,
588
- reachable: status.reachable,
589
- cpuLoad: status.cpuLoad,
590
- memUsedPct: status.memUsedPct,
591
- uptime: status.uptime
592
- });
593
- const m = normMac(config.mac);
594
- if (m)
595
- macToDevice.set(m, name);
596
- if (config.host)
597
- ipToDevice.set(config.host, name);
598
- if (status.identity)
599
- identityToDevice.set(status.identity.toLowerCase(), name);
600
- identityToDevice.set(name.toLowerCase(), name);
601
- }
602
- const matchConfigured = (n) => {
603
- const m = normMac(n.mac);
604
- if (m && macToDevice.has(m))
605
- return macToDevice.get(m);
606
- if (n.ip && ipToDevice.has(n.ip))
607
- return ipToDevice.get(n.ip);
608
- if (n.identity && identityToDevice.has(n.identity.toLowerCase())) {
609
- return identityToDevice.get(n.identity.toLowerCase());
610
- }
611
- return;
612
- };
613
- const edges = [];
614
- const edgeKeys = new Set;
615
- const takenNames = new Set(input.devices.map((d) => d.name));
616
- const addEdge = (from, to, iface) => {
617
- if (from === to)
618
- return;
619
- const key = `${from}\x00${to}\x00${iface ?? ""}`;
620
- if (edgeKeys.has(key))
621
- return;
622
- edgeKeys.add(key);
623
- edges.push({ from, to, interface: iface });
624
- };
625
- const neighborId = (n) => {
626
- const m = normMac(n.mac);
627
- if (m)
628
- return `nb:mac:${m}`;
629
- if (n.identity)
630
- return `nb:id:${n.identity.toLowerCase()}`;
631
- return `nb:ip:${n.ip ?? "unknown"}`;
632
- };
633
- for (const { name } of input.devices) {
634
- const seen = input.neighborsByDevice[name] ?? [];
635
- for (const n of seen) {
636
- const configuredId = matchConfigured(n);
637
- if (configuredId) {
638
- addEdge(name, configuredId, n.interface);
639
- continue;
640
- }
641
- const id = neighborId(n);
642
- const existing = nodes.get(id);
643
- if (existing) {
644
- existing.ip ??= n.ip;
645
- existing.mac ??= n.mac;
646
- existing.identity ??= n.identity;
647
- existing.board ??= n.board;
648
- existing.version ??= n.version;
649
- if (!existing.label && n.identity)
650
- existing.label = n.identity;
651
- } else {
652
- nodes.set(id, {
653
- id,
654
- kind: "neighbor",
655
- label: n.identity || n.mac || n.ip || "unknown",
656
- configured: false,
657
- onboardable: true,
658
- identity: n.identity,
659
- ip: n.ip,
660
- mac: n.mac,
661
- platform: n.platform,
662
- board: n.board,
663
- version: n.version,
664
- suggestedConfig: {
665
- name: suggestName(n, takenNames),
666
- host: n.ip,
667
- mac: n.ip ? undefined : n.mac,
668
- port: 22,
669
- username: "admin"
670
- }
671
- });
672
- }
673
- addEdge(name, id, n.interface);
674
- }
675
- }
676
- const nodeList = [...nodes.values()];
677
- return {
678
- nodes: nodeList,
679
- edges,
680
- stats: {
681
- devices: input.devices.length,
682
- neighbors: nodeList.filter((n) => n.kind === "neighbor").length,
683
- onboardable: nodeList.filter((n) => n.onboardable).length
684
- }
685
- };
686
- }
687
-
688
- // src/observability/health.ts
689
- var LOG_TAG = "mikrotik-mcp";
690
- var HISTORY_CAP = 60;
691
- var statuses = new Map;
692
- var histories = new Map;
693
- var neighbors = new Map;
694
- var timer = null;
695
- var inFlight = false;
696
- var UNKNOWN = { reachable: null, checkedAt: null, latencyMs: null };
697
- function getDeviceStatus(name) {
698
- return statuses.get(name) ?? UNKNOWN;
699
- }
700
- function getDeviceNeighbors(name) {
701
- return neighbors.get(name) ?? [];
702
- }
703
- function getDeviceHistory(name) {
704
- return histories.get(name) ?? [];
705
- }
706
- function pushHistory(name, sample) {
707
- const arr = histories.get(name) ?? [];
708
- arr.push(sample);
709
- if (arr.length > HISTORY_CAP)
710
- arr.splice(0, arr.length - HISTORY_CAP);
711
- histories.set(name, arr);
712
- }
713
- async function probeDevice(name, dc) {
714
- const client = createDeviceClient({
715
- ...dc,
716
- timeoutMs: Math.min(dc.timeoutMs ?? 1e4, 8000)
717
- });
718
- const t0 = Date.now();
719
- let status;
720
- try {
721
- const ok = await client.connect();
722
- if (!ok) {
723
- status = {
724
- reachable: false,
725
- checkedAt: Date.now(),
726
- latencyMs: null,
727
- error: client.lastError ?? "connection failed"
728
- };
729
- } else {
730
- const rawResource = await client.run("/system resource print");
731
- const checkedAt = Date.now();
732
- const latencyMs = checkedAt - t0;
733
- const sys = parseSystemResource(rawResource);
734
- if (!sys) {
735
- logger.warn(`[${LOG_TAG}] '${name}' is reachable but '/system resource print' returned no ` + `parseable metrics. Raw output: ${JSON.stringify(rawResource.slice(0, 200))}`);
736
- }
737
- let identity;
738
- try {
739
- identity = parseKeyValues(await client.run("/system identity print")).name || undefined;
740
- } catch {}
741
- status = {
742
- reachable: true,
743
- checkedAt,
744
- latencyMs,
745
- identity,
746
- version: sys?.version,
747
- boardName: sys?.boardName,
748
- architecture: sys?.architecture,
749
- cpuCount: sys?.cpuCount,
750
- cpuLoad: sys?.cpuLoad,
751
- freeMemory: sys?.freeMemory,
752
- totalMemory: sys?.totalMemory,
753
- memUsedPct: sys?.memUsedPct,
754
- freeHdd: sys?.freeHdd,
755
- totalHdd: sys?.totalHdd,
756
- hddUsedPct: sys?.hddUsedPct,
757
- uptime: sys?.uptime,
758
- error: sys ? undefined : "reachable, but no system metrics returned"
759
- };
760
- pushHistory(name, {
761
- ts: checkedAt,
762
- cpuLoad: sys?.cpuLoad ?? null,
763
- memUsedPct: sys?.memUsedPct ?? null,
764
- hddUsedPct: sys?.hddUsedPct ?? null,
765
- latencyMs
766
- });
767
- try {
768
- neighbors.set(name, parseNeighbors(await client.run("/ip neighbor print detail")));
769
- } catch {}
770
- try {
771
- status.disks = parseDisks(await client.run("/disk print detail"));
772
- } catch {}
773
- }
774
- } catch (e) {
775
- status = {
776
- reachable: false,
777
- checkedAt: Date.now(),
778
- latencyMs: null,
779
- error: e instanceof Error ? e.message : String(e)
780
- };
781
- } finally {
782
- client.disconnect();
783
- }
784
- statuses.set(name, status);
785
- return status;
786
- }
787
- var MAC_PROBE_INTERVAL_MS = 5 * 60000;
788
- var lastMacProbe = new Map;
789
- async function probeAll() {
790
- if (inFlight)
791
- return;
792
- inFlight = true;
793
- try {
794
- const cfg = getConfig();
795
- const now = Date.now();
796
- await Promise.all(Object.entries(cfg.devices).map(([name, dc]) => {
797
- if (dc.mac) {
798
- if (now - (lastMacProbe.get(name) ?? 0) < MAC_PROBE_INTERVAL_MS)
799
- return Promise.resolve();
800
- lastMacProbe.set(name, now);
801
- }
802
- return probeDevice(name, dc).catch(() => {});
803
- }));
804
- } finally {
805
- inFlight = false;
806
- }
807
- }
808
- function startHealthChecks(intervalMs = 30000) {
809
- probeAll();
810
- timer = setInterval(() => void probeAll(), intervalMs);
811
- }
812
- function stopHealthChecks() {
813
- if (timer) {
814
- clearInterval(timer);
815
- timer = null;
816
- }
817
- }
818
-
819
657
  // src/observability/geo.ts
820
658
  import { lookup } from "dns/promises";
821
- var LOG_TAG2 = "mikrotik-mcp";
659
+ var LOG_TAG = "mikrotik-mcp";
822
660
  var cache = new Map;
823
661
  var REFRESH_MS = 24 * 60 * 60000;
824
- var timer2 = null;
825
- var inFlight2 = false;
662
+ var timer = null;
663
+ var inFlight = false;
826
664
  function getDeviceGeo(name) {
827
665
  return cache.get(name)?.geo ?? null;
828
666
  }
@@ -870,12 +708,12 @@ async function fetchGeo(ip) {
870
708
  try {
871
709
  return await fetchIpkit(ip);
872
710
  } catch (e) {
873
- logger.warn(`[${LOG_TAG2}] ipkit geo failed for ${ip}, trying ipquery: ${e instanceof Error ? e.message : String(e)}`);
711
+ logger.warn(`[${LOG_TAG}] ipkit geo failed for ${ip}, trying ipquery: ${e instanceof Error ? e.message : String(e)}`);
874
712
  }
875
713
  try {
876
714
  return await fetchIpquery(ip);
877
715
  } catch (e) {
878
- logger.warn(`[${LOG_TAG2}] geo lookup failed for ${ip}: ${e instanceof Error ? e.message : String(e)}`);
716
+ logger.warn(`[${LOG_TAG}] geo lookup failed for ${ip}: ${e instanceof Error ? e.message : String(e)}`);
879
717
  return null;
880
718
  }
881
719
  }
@@ -885,9 +723,9 @@ async function resolveDevice(name, host) {
885
723
  cache.set(name, { geo, at: Date.now() });
886
724
  }
887
725
  async function refreshGeo() {
888
- if (inFlight2)
726
+ if (inFlight)
889
727
  return;
890
- inFlight2 = true;
728
+ inFlight = true;
891
729
  try {
892
730
  const now = Date.now();
893
731
  await Promise.all(Object.entries(getConfig().devices).map(([name, dc]) => {
@@ -897,17 +735,17 @@ async function refreshGeo() {
897
735
  return resolveDevice(name, dc.mac ? undefined : dc.host);
898
736
  }));
899
737
  } finally {
900
- inFlight2 = false;
738
+ inFlight = false;
901
739
  }
902
740
  }
903
741
  function startGeoLookups() {
904
742
  refreshGeo();
905
- timer2 = setInterval(() => void refreshGeo(), REFRESH_MS);
743
+ timer = setInterval(() => void refreshGeo(), REFRESH_MS);
906
744
  }
907
745
  function stopGeoLookups() {
908
- if (timer2) {
909
- clearInterval(timer2);
910
- timer2 = null;
746
+ if (timer) {
747
+ clearInterval(timer);
748
+ timer = null;
911
749
  }
912
750
  }
913
751
 
@@ -915,7 +753,7 @@ function stopGeoLookups() {
915
753
  import { join as join3 } from "path";
916
754
  import { mkdir, readFile, writeFile } from "fs/promises";
917
755
  import { homedir } from "os";
918
- var LOG_TAG3 = "mikrotik-mcp";
756
+ var LOG_TAG2 = "mikrotik-mcp";
919
757
  var CODE_RE = /^[a-z]{2}$/;
920
758
  var sourceUrl = (code) => `https://hatscripts.github.io/circle-flags/flags/${code}.svg`;
921
759
  var VENDOR_DIR = join3(PROJECT_ROOT, "assets", "flags");
@@ -947,7 +785,7 @@ async function flagSvg(code) {
947
785
  mkdir(CACHE_DIR, { recursive: true }).then(() => writeFile(join3(CACHE_DIR, `${c}.svg`), svg)).catch(() => {});
948
786
  return svg;
949
787
  } catch (e) {
950
- logger.warn(`[${LOG_TAG3}] flag fetch failed for '${c}': ${e instanceof Error ? e.message : String(e)}`);
788
+ logger.warn(`[${LOG_TAG2}] flag fetch failed for '${c}': ${e instanceof Error ? e.message : String(e)}`);
951
789
  return null;
952
790
  }
953
791
  }
@@ -1010,6 +848,8 @@ function rowToEvent(r) {
1010
848
  risk: r.risk,
1011
849
  device: r.device ?? undefined,
1012
850
  transport: r.transport ?? undefined,
851
+ deviceTransport: r.device_transport ?? undefined,
852
+ restFallback: r.rest_fallback ?? undefined,
1013
853
  durationMs: r.duration_ms,
1014
854
  isError: r.is_error === 1,
1015
855
  error: r.error ?? undefined,
@@ -1038,13 +878,19 @@ var SCHEMA_STATEMENTS = [
1038
878
  output_bytes INTEGER NOT NULL,
1039
879
  has_structured INTEGER NOT NULL,
1040
880
  truncated INTEGER NOT NULL,
1041
- reason TEXT
881
+ reason TEXT,
882
+ device_transport TEXT,
883
+ rest_fallback TEXT
1042
884
  )`,
1043
885
  "CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts)",
1044
886
  "CREATE INDEX IF NOT EXISTS idx_events_tool ON events(tool)",
1045
887
  "CREATE INDEX IF NOT EXISTS idx_events_error ON events(is_error)"
1046
888
  ];
1047
- var MIGRATIONS = ["ALTER TABLE events ADD COLUMN reason TEXT"];
889
+ var MIGRATIONS = [
890
+ "ALTER TABLE events ADD COLUMN reason TEXT",
891
+ "ALTER TABLE events ADD COLUMN device_transport TEXT",
892
+ "ALTER TABLE events ADD COLUMN rest_fallback TEXT"
893
+ ];
1048
894
 
1049
895
  class SqliteEventStore {
1050
896
  db;
@@ -1062,9 +908,11 @@ class SqliteEventStore {
1062
908
  }
1063
909
  insert(e) {
1064
910
  this.db.query(`INSERT OR REPLACE INTO events
1065
- (id, ts, tool, title, risk, device, transport, duration_ms, is_error, error,
911
+ (id, ts, tool, title, risk, device, transport, device_transport, rest_fallback,
912
+ duration_ms, is_error, error,
1066
913
  input, output, output_bytes, has_structured, truncated, reason)
1067
- VALUES ($id,$ts,$tool,$title,$risk,$device,$transport,$dur,$err,$errmsg,
914
+ VALUES ($id,$ts,$tool,$title,$risk,$device,$transport,$devtransport,$restfallback,
915
+ $dur,$err,$errmsg,
1068
916
  $input,$output,$obytes,$structured,$trunc,$reason)`).run({
1069
917
  $id: e.id,
1070
918
  $ts: e.ts,
@@ -1073,6 +921,8 @@ class SqliteEventStore {
1073
921
  $risk: e.risk,
1074
922
  $device: e.device ?? null,
1075
923
  $transport: e.transport ?? null,
924
+ $devtransport: e.deviceTransport ?? null,
925
+ $restfallback: e.restFallback ?? null,
1076
926
  $dur: e.durationMs,
1077
927
  $err: e.isError ? 1 : 0,
1078
928
  $errmsg: e.error ?? null,
@@ -1277,8 +1127,8 @@ var CAPSMAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1277
1127
  var DEFAULT_CAPSMAN_INTERVAL_MS = 5 * 60000;
1278
1128
  var MIN_CAPSMAN_INTERVAL_MS = 60000;
1279
1129
  var MAX_CAPSMAN_INTERVAL_MS = 6 * 60 * 60000;
1280
- var timer3 = null;
1281
- var inFlight3 = false;
1130
+ var timer2 = null;
1131
+ var inFlight2 = false;
1282
1132
  var noCapsman = new Set;
1283
1133
  function clampInterval(ms) {
1284
1134
  if (!Number.isFinite(ms))
@@ -1305,9 +1155,9 @@ async function sampleDevice(store, device, ts) {
1305
1155
  store.recordRadioSamples(device, ts, samples);
1306
1156
  }
1307
1157
  async function sampleCapsmanOnce(store) {
1308
- if (inFlight3)
1158
+ if (inFlight2)
1309
1159
  return;
1310
- inFlight3 = true;
1160
+ inFlight2 = true;
1311
1161
  const ts = Date.now();
1312
1162
  try {
1313
1163
  const cfg = getConfig();
@@ -1322,18 +1172,18 @@ async function sampleCapsmanOnce(store) {
1322
1172
  }));
1323
1173
  store.pruneSamples(ts - CAPSMAN_RETENTION_MS);
1324
1174
  } finally {
1325
- inFlight3 = false;
1175
+ inFlight2 = false;
1326
1176
  }
1327
1177
  }
1328
1178
  function startCapsmanSampler(store, intervalMs = DEFAULT_CAPSMAN_INTERVAL_MS) {
1329
1179
  const ms = clampInterval(intervalMs);
1330
1180
  sampleCapsmanOnce(store);
1331
- timer3 = setInterval(() => void sampleCapsmanOnce(store), ms);
1181
+ timer2 = setInterval(() => void sampleCapsmanOnce(store), ms);
1332
1182
  }
1333
1183
  function stopCapsmanSampler() {
1334
- if (timer3) {
1335
- clearInterval(timer3);
1336
- timer3 = null;
1184
+ if (timer2) {
1185
+ clearInterval(timer2);
1186
+ timer2 = null;
1337
1187
  }
1338
1188
  }
1339
1189
 
@@ -1473,8 +1323,8 @@ var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
1473
1323
  var DEFAULT_USAGE_INTERVAL_MS = 60000;
1474
1324
  var MIN_USAGE_INTERVAL_MS = 30000;
1475
1325
  var MAX_USAGE_INTERVAL_MS = 6 * 60 * 60000;
1476
- var timer4 = null;
1477
- var inFlight4 = false;
1326
+ var timer3 = null;
1327
+ var inFlight3 = false;
1478
1328
  var currentStore = null;
1479
1329
  var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
1480
1330
  function clampInterval2(ms) {
@@ -1535,9 +1385,9 @@ async function ingestSessions(store, device) {
1535
1385
  store.upsertSessions(device, sessions);
1536
1386
  }
1537
1387
  async function sampleUsageOnce(store) {
1538
- if (inFlight4)
1388
+ if (inFlight3)
1539
1389
  return;
1540
- inFlight4 = true;
1390
+ inFlight3 = true;
1541
1391
  const ts = Date.now();
1542
1392
  try {
1543
1393
  const cfg = getConfig();
@@ -1553,32 +1403,32 @@ async function sampleUsageOnce(store) {
1553
1403
  }));
1554
1404
  store.pruneSamples(ts - USAGE_RETENTION_MS);
1555
1405
  } finally {
1556
- inFlight4 = false;
1406
+ inFlight3 = false;
1557
1407
  }
1558
1408
  }
1559
1409
  function startUsageSampler(store, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
1560
1410
  currentStore = store;
1561
1411
  currentIntervalMs = clampInterval2(intervalMs);
1562
1412
  sampleUsageOnce(store);
1563
- timer4 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1413
+ timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1564
1414
  }
1565
1415
  function getUsageSamplerInterval() {
1566
1416
  return currentIntervalMs;
1567
1417
  }
1568
1418
  function setUsageSamplerInterval(intervalMs) {
1569
1419
  currentIntervalMs = clampInterval2(intervalMs);
1570
- if (timer4)
1571
- clearInterval(timer4);
1420
+ if (timer3)
1421
+ clearInterval(timer3);
1572
1422
  if (currentStore) {
1573
1423
  const store = currentStore;
1574
- timer4 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1424
+ timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
1575
1425
  }
1576
1426
  return currentIntervalMs;
1577
1427
  }
1578
1428
  function stopUsageSampler() {
1579
- if (timer4) {
1580
- clearInterval(timer4);
1581
- timer4 = null;
1429
+ if (timer3) {
1430
+ clearInterval(timer3);
1431
+ timer3 = null;
1582
1432
  }
1583
1433
  currentStore = null;
1584
1434
  }
@@ -1822,6 +1672,7 @@ async function driftRoutes(req, url) {
1822
1672
  toLabel: `${device}@live`
1823
1673
  });
1824
1674
  const report = analyzeDrift(diff, device, baseline.snapshotId, baseline.setAt);
1675
+ noteDriftResult(report);
1825
1676
  try {
1826
1677
  const logCmd = `/log print where topics~"system" and time > ([:timestamp] - 7d)`;
1827
1678
  const logText = await executeMikrotikCommand(logCmd, ctx);
@@ -1837,12 +1688,1276 @@ async function driftRoutes(req, url) {
1837
1688
  return null;
1838
1689
  }
1839
1690
 
1840
- // src/observability/memory-routes.ts
1691
+ // src/observability/txn-routes.ts
1841
1692
  var JSON_HEADERS2 = { "content-type": "application/json; charset=utf-8" };
1842
1693
  function json2(body, status = 200) {
1843
1694
  return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS2 });
1844
1695
  }
1696
+ function liveRecord(id) {
1697
+ const entry = getTxn(id);
1698
+ return entry ? { ...toRecord(entry.txn, entry.ts, entry.label), updated: Date.now() } : null;
1699
+ }
1700
+ async function txnRoutes(req, url) {
1701
+ const p = url.pathname;
1702
+ if (!p.startsWith("/api/txn"))
1703
+ return null;
1704
+ let storeError;
1705
+ const store = await txnStore().catch((e) => {
1706
+ storeError = e instanceof Error ? e.message : String(e);
1707
+ return null;
1708
+ });
1709
+ if (p === "/api/txn" && req.method === "GET") {
1710
+ const limit = Number(url.searchParams.get("limit") ?? 50);
1711
+ const stored = store?.list(limit) ?? [];
1712
+ const live = listTxns().map((e) => ({
1713
+ ...toRecord(e.txn, e.ts, e.label),
1714
+ updated: Date.now()
1715
+ }));
1716
+ const byId = new Map(stored.map((r) => [r.id, r]));
1717
+ for (const r of live)
1718
+ byId.set(r.id, r);
1719
+ const transactions = [...byId.values()].sort((a, b) => b.ts - a.ts).slice(0, limit);
1720
+ return json2({ transactions, live: liveTxnUpdates(), error: storeError });
1721
+ }
1722
+ const parts = p.slice("/api/txn/".length).split("/").filter(Boolean);
1723
+ if (parts.length === 0 || parts.length > 2)
1724
+ return json2({ error: "not found" }, 404);
1725
+ const id = decodeURIComponent(parts[0]);
1726
+ if (parts[1] === "abort" && req.method === "POST") {
1727
+ const entry = getTxn(id);
1728
+ if (!entry)
1729
+ return json2({ error: `no open transaction '${id}'` }, 404);
1730
+ entry.txn = requestAbort(entry.txn, "aborted from the dashboard");
1731
+ const run = await runTransaction({
1732
+ txn: entry.txn,
1733
+ steps: entry.steps,
1734
+ executor: createDeviceExecutor(createContext(), entry.txn.id)
1735
+ });
1736
+ entry.txn = run.txn;
1737
+ await persistTxn(entry);
1738
+ if (run.state !== undefined)
1739
+ dropTxn(id);
1740
+ return json2({
1741
+ state: run.state,
1742
+ summary: run.summary,
1743
+ transaction: toRecord(run.txn, entry.ts)
1744
+ });
1745
+ }
1746
+ if (parts.length === 1 && req.method === "GET") {
1747
+ const record = liveRecord(id) ?? store?.get(id) ?? null;
1748
+ if (!record)
1749
+ return json2({ error: `unknown transaction '${id}'` }, 404);
1750
+ return json2({ transaction: record, events: store?.events(id) ?? [] });
1751
+ }
1752
+ return json2({ error: "method not allowed" }, 405);
1753
+ }
1754
+
1755
+ // src/observability/flow-routes.ts
1756
+ var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
1757
+ function json3(body, status = 200) {
1758
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS3 });
1759
+ }
1760
+ var WINDOWS = {
1761
+ "5m": 5 * 60000,
1762
+ "15m": 15 * 60000,
1763
+ "1h": 3600000,
1764
+ "6h": 6 * 3600000,
1765
+ "24h": 24 * 3600000
1766
+ };
1767
+ function bucketFor(windowMs) {
1768
+ return Math.max(60000, Math.round(windowMs / 60 / 60000) * 60000);
1769
+ }
1770
+ function windowMs(url) {
1771
+ return WINDOWS[url.searchParams.get("window") ?? "1h"] ?? WINDOWS["1h"];
1772
+ }
1773
+ async function flowRoutes(req, url) {
1774
+ const p = url.pathname;
1775
+ if (!p.startsWith("/api/flows"))
1776
+ return null;
1777
+ if (req.method !== "GET")
1778
+ return json3({ error: "method not allowed" }, 405);
1779
+ const collector = getFlowCollector();
1780
+ if (p === "/api/flows/health") {
1781
+ let store = null;
1782
+ try {
1783
+ store = (await flowStore()).stats();
1784
+ } catch {}
1785
+ return json3({ collector: collector.stats(), store });
1786
+ }
1787
+ collector.drain();
1788
+ let records;
1789
+ const span = windowMs(url);
1790
+ const to = Date.now();
1791
+ const from = to - span;
1792
+ const address = url.searchParams.get("address") ?? undefined;
1793
+ try {
1794
+ records = (await flowStore()).query({ from, to, address, limit: 200000 });
1795
+ } catch (e) {
1796
+ return json3({ error: e instanceof Error ? e.message : String(e), records: [] }, 200);
1797
+ }
1798
+ if (p === "/api/flows/top") {
1799
+ const dimension = url.searchParams.get("dimension") ?? "source";
1800
+ const limit = Number(url.searchParams.get("limit") ?? 10);
1801
+ return json3({
1802
+ window: { from, to },
1803
+ totals: summarize(records),
1804
+ top: topTalkers(records, dimension, limit, true),
1805
+ protocols: protocolMix(records).slice(0, 8),
1806
+ applications: topTalkers(records, "application", 8, true)
1807
+ });
1808
+ }
1809
+ if (p === "/api/flows/conversations") {
1810
+ const limit = Number(url.searchParams.get("limit") ?? 20);
1811
+ return json3({
1812
+ window: { from, to },
1813
+ conversations: conversations(records, limit)
1814
+ });
1815
+ }
1816
+ if (p === "/api/flows/timeline") {
1817
+ const dimension = url.searchParams.get("dimension") ?? "source";
1818
+ const topN = Number(url.searchParams.get("topN") ?? 5);
1819
+ const bucket = bucketFor(span);
1820
+ return json3({
1821
+ window: { from, to },
1822
+ bucketMs: bucket,
1823
+ keys: topTalkers(records, dimension, topN).map((t) => t.key),
1824
+ buckets: timeline(records, from, to, bucket, dimension, topN)
1825
+ });
1826
+ }
1827
+ return json3({ error: "not found" }, 404);
1828
+ }
1829
+
1830
+ // src/observability/rollout-routes.ts
1831
+ var JSON_HEADERS4 = { "content-type": "application/json; charset=utf-8" };
1832
+ function json4(body, status = 200) {
1833
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS4 });
1834
+ }
1835
+ function liveRecord2(id) {
1836
+ const entry = getRollout(id);
1837
+ return entry ? {
1838
+ ...toRecord2(entry.state, entry.ts, { label: entry.label, commands: entry.commands }),
1839
+ updated: Date.now()
1840
+ } : null;
1841
+ }
1842
+ async function rolloutRoutes(req, url) {
1843
+ const p = url.pathname;
1844
+ if (!p.startsWith("/api/rollout"))
1845
+ return null;
1846
+ let storeError;
1847
+ const store = await rolloutStore().catch((e) => {
1848
+ storeError = e instanceof Error ? e.message : String(e);
1849
+ return null;
1850
+ });
1851
+ if (p === "/api/rollout" && req.method === "GET") {
1852
+ const limit = Number(url.searchParams.get("limit") ?? 50);
1853
+ const stored = store?.list(limit) ?? [];
1854
+ const live = listRollouts().map((e) => ({
1855
+ ...toRecord2(e.state, e.ts, { label: e.label, commands: e.commands }),
1856
+ updated: Date.now()
1857
+ }));
1858
+ const byId = new Map(stored.map((r) => [r.id, r]));
1859
+ for (const r of live)
1860
+ byId.set(r.id, r);
1861
+ const rollouts = [...byId.values()].sort((a, b) => b.ts - a.ts).slice(0, limit);
1862
+ return json4({ rollouts, live: liveRolloutUpdates(), error: storeError });
1863
+ }
1864
+ const parts = p.slice("/api/rollout/".length).split("/").filter(Boolean);
1865
+ if (parts.length === 0 || parts.length > 2)
1866
+ return json4({ error: "not found" }, 404);
1867
+ const id = decodeURIComponent(parts[0]);
1868
+ const verb = parts[1];
1869
+ if (verb && req.method === "POST") {
1870
+ const entry = getRollout(id);
1871
+ if (!entry)
1872
+ return json4({ error: `no active rollout '${id}'` }, 404);
1873
+ switch (verb) {
1874
+ case "hold": {
1875
+ entry.state = requestHold(entry.state);
1876
+ await persistRollout(entry);
1877
+ publishRollout({
1878
+ rolloutId: id,
1879
+ ts: Date.now(),
1880
+ phase: entry.state.phase,
1881
+ currentWave: entry.state.currentWave,
1882
+ action: "hold",
1883
+ ok: true,
1884
+ devices: entry.state.devices,
1885
+ gates: entry.state.gates
1886
+ });
1887
+ return json4({ held: true, summary: summarize2(entry.state) });
1888
+ }
1889
+ case "resume": {
1890
+ entry.state = resume(entry.state);
1891
+ const run = await runRollout({
1892
+ state: entry.state,
1893
+ commands: entry.commands,
1894
+ executor: createDeviceExecutor2(createContext(), id),
1895
+ onEvent: ({ action, state }) => {
1896
+ entry.state = state;
1897
+ publishRollout({
1898
+ rolloutId: id,
1899
+ ts: Date.now(),
1900
+ phase: state.phase,
1901
+ currentWave: state.currentWave,
1902
+ action: action.kind,
1903
+ device: "device" in action ? action.device : undefined,
1904
+ ok: true,
1905
+ outcome: state.outcome,
1906
+ devices: state.devices,
1907
+ gates: state.gates
1908
+ });
1909
+ }
1910
+ });
1911
+ entry.state = run.state;
1912
+ await persistRollout(entry);
1913
+ if (run.outcome)
1914
+ dropRollout(id);
1915
+ return json4({ outcome: run.outcome, summary: run.summary });
1916
+ }
1917
+ case "abort": {
1918
+ entry.state = requestAbort2(entry.state, "aborted from the dashboard");
1919
+ const run = await runRollout({
1920
+ state: entry.state,
1921
+ commands: entry.commands,
1922
+ executor: createDeviceExecutor2(createContext(), id)
1923
+ });
1924
+ entry.state = run.state;
1925
+ await persistRollout(entry);
1926
+ if (run.outcome)
1927
+ dropRollout(id);
1928
+ return json4({ outcome: run.outcome, summary: run.summary });
1929
+ }
1930
+ default:
1931
+ return json4({ error: `unknown action '${verb}'` }, 404);
1932
+ }
1933
+ }
1934
+ if (parts.length === 1 && req.method === "GET") {
1935
+ const record = liveRecord2(id) ?? store?.get(id) ?? null;
1936
+ if (!record)
1937
+ return json4({ error: `unknown rollout '${id}'` }, 404);
1938
+ return json4({ rollout: record, events: store?.events(id) ?? [] });
1939
+ }
1940
+ return json4({ error: "method not allowed" }, 405);
1941
+ }
1942
+
1943
+ // src/policy/results.ts
1944
+ import { mkdirSync as mkdirSync6 } from "fs";
1945
+ import { dirname as dirname5 } from "path";
1946
+ var SCHEMA_STATEMENTS4 = [
1947
+ `CREATE TABLE IF NOT EXISTS policy_results (
1948
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1949
+ device TEXT NOT NULL,
1950
+ ts INTEGER NOT NULL,
1951
+ score INTEGER NOT NULL,
1952
+ passed INTEGER NOT NULL,
1953
+ failed INTEGER NOT NULL,
1954
+ not_applicable INTEGER NOT NULL,
1955
+ by_severity TEXT NOT NULL,
1956
+ findings TEXT NOT NULL
1957
+ )`,
1958
+ "CREATE INDEX IF NOT EXISTS idx_policy_results_device_ts ON policy_results(device, ts)"
1959
+ ];
1960
+
1961
+ class SqlitePolicyResultStore {
1962
+ db;
1963
+ constructor(db) {
1964
+ this.db = db;
1965
+ db.run("PRAGMA journal_mode = WAL");
1966
+ db.run("PRAGMA synchronous = NORMAL");
1967
+ for (const stmt of SCHEMA_STATEMENTS4)
1968
+ db.run(stmt);
1969
+ }
1970
+ insert(report) {
1971
+ this.db.query(`INSERT INTO policy_results
1972
+ (device, ts, score, passed, failed, not_applicable, by_severity, findings)
1973
+ VALUES ($device,$ts,$score,$passed,$failed,$na,$bySeverity,$findings)`).run({
1974
+ $device: report.device ?? "(snapshot)",
1975
+ $ts: report.ts ?? Date.now(),
1976
+ $score: report.summary.score,
1977
+ $passed: report.summary.passed,
1978
+ $failed: report.summary.failed,
1979
+ $na: report.summary.notApplicable,
1980
+ $bySeverity: JSON.stringify(report.summary.bySeverity),
1981
+ $findings: JSON.stringify(report.findings.filter((f) => f.status === "fail"))
1982
+ });
1983
+ }
1984
+ list(device, limit = 50) {
1985
+ const rows = device ? this.db.query("SELECT * FROM policy_results WHERE device = $device ORDER BY ts DESC LIMIT $limit").all({ $device: device, $limit: limit }) : this.db.query("SELECT * FROM policy_results ORDER BY ts DESC LIMIT $limit").all({ $limit: limit });
1986
+ return rows.map((r) => ({
1987
+ id: r.id,
1988
+ device: r.device,
1989
+ ts: r.ts,
1990
+ score: r.score,
1991
+ passed: r.passed,
1992
+ failed: r.failed,
1993
+ notApplicable: r.not_applicable,
1994
+ bySeverity: JSON.parse(r.by_severity),
1995
+ findings: JSON.parse(r.findings)
1996
+ }));
1997
+ }
1998
+ close() {
1999
+ this.db.close();
2000
+ }
2001
+ }
2002
+ async function openPolicyResultStore(path) {
2003
+ if (path !== ":memory:") {
2004
+ try {
2005
+ mkdirSync6(dirname5(path), { recursive: true });
2006
+ } catch {}
2007
+ }
2008
+ const { Database } = await import("bun:sqlite");
2009
+ const db = new Database(path, { create: true });
2010
+ return new SqlitePolicyResultStore(db);
2011
+ }
2012
+ var storePromise2 = null;
2013
+ function store() {
2014
+ storePromise2 ??= openPolicyResultStore(DEFAULT_SNAPSHOT_DB);
2015
+ return storePromise2;
2016
+ }
2017
+ async function recordPolicyResult(report) {
2018
+ try {
2019
+ (await store()).insert(report);
2020
+ } catch (e) {
2021
+ logger.error(`policy result write failed: ${e instanceof Error ? e.message : String(e)}`);
2022
+ }
2023
+ }
2024
+ async function policyResults(device, limit = 50) {
2025
+ return (await store()).list(device, limit);
2026
+ }
2027
+
2028
+ // src/observability/policy-routes.ts
2029
+ var JSON_HEADERS5 = { "content-type": "application/json; charset=utf-8" };
2030
+ function json5(body, status = 200) {
2031
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS5 });
2032
+ }
1845
2033
  async function bodyJson2(req) {
2034
+ return await req.json().catch(() => ({}));
2035
+ }
2036
+ async function policyRoutes(req, url) {
2037
+ const p = url.pathname;
2038
+ if (!p.startsWith("/api/policies"))
2039
+ return null;
2040
+ if (p === "/api/policies" && req.method === "GET") {
2041
+ const set = currentPolicySet();
2042
+ return json5({
2043
+ files: set.files.map((f) => ({
2044
+ path: f.path,
2045
+ name: f.name,
2046
+ ok: f.ok,
2047
+ issues: f.issues,
2048
+ policies: f.policies
2049
+ })),
2050
+ ruleCount: set.policies.length,
2051
+ duplicateIds: set.duplicateIds,
2052
+ emptyPatterns: set.emptyPatterns,
2053
+ paths: getConfig().policy.paths
2054
+ });
2055
+ }
2056
+ if (p === "/api/policies/results" && req.method === "GET") {
2057
+ const device = url.searchParams.get("device") ?? undefined;
2058
+ const limit = Number(url.searchParams.get("limit") ?? 50);
2059
+ let results;
2060
+ try {
2061
+ results = await policyResults(device, limit);
2062
+ } catch (e) {
2063
+ return json5({ results: [], error: e instanceof Error ? e.message : String(e) });
2064
+ }
2065
+ return json5({ results });
2066
+ }
2067
+ if (p === "/api/policies/validate" && req.method === "POST") {
2068
+ const body = await bodyJson2(req);
2069
+ if (typeof body.content !== "string")
2070
+ return json5({ error: "content is required" }, 400);
2071
+ return json5(validatePolicyText(body.content));
2072
+ }
2073
+ if (p === "/api/policies/run" && req.method === "POST") {
2074
+ const body = await bodyJson2(req);
2075
+ const set = currentPolicySet();
2076
+ if (set.policies.length === 0) {
2077
+ return json5({ error: "no policy rules are loaded", reports: [] });
2078
+ }
2079
+ const cfg = getConfig();
2080
+ const devices = body.devices && body.devices.length > 0 ? body.devices : Object.entries(cfg.devices).filter(([, d]) => !d.disabled).map(([name]) => name);
2081
+ const reports = [];
2082
+ for (const device of devices) {
2083
+ const ctx = createContext(undefined, device);
2084
+ try {
2085
+ const text = await executeMikrotikCommand("/export terse", ctx);
2086
+ if (looksLikeError(text) || text.trim() === "") {
2087
+ reports.push({ device, error: text.trim() || "empty export" });
2088
+ continue;
2089
+ }
2090
+ const report = evaluatePolicies(set.policies, parseExport(text), {
2091
+ device,
2092
+ ts: Date.now()
2093
+ });
2094
+ rememberReport(report);
2095
+ await recordPolicyResult(report);
2096
+ reports.push({
2097
+ device,
2098
+ summary: report.summary,
2099
+ findings: report.findings,
2100
+ markdown: renderReport(report, "markdown")
2101
+ });
2102
+ } catch (e) {
2103
+ reports.push({ device, error: e instanceof Error ? e.message : String(e) });
2104
+ }
2105
+ }
2106
+ return json5({ reports });
2107
+ }
2108
+ return json5({ error: "not found" }, 404);
2109
+ }
2110
+
2111
+ // src/sim/suites.ts
2112
+ import { mkdirSync as mkdirSync7 } from "fs";
2113
+ import { dirname as dirname6 } from "path";
2114
+ var SCHEMA = [
2115
+ `CREATE TABLE IF NOT EXISTS sim_suites (
2116
+ id TEXT PRIMARY KEY,
2117
+ name TEXT NOT NULL,
2118
+ packets TEXT NOT NULL,
2119
+ updated INTEGER NOT NULL
2120
+ )`
2121
+ ];
2122
+
2123
+ class SqliteSuiteStore {
2124
+ db;
2125
+ constructor(db) {
2126
+ this.db = db;
2127
+ db.run("PRAGMA journal_mode = WAL");
2128
+ for (const stmt of SCHEMA)
2129
+ db.run(stmt);
2130
+ }
2131
+ list() {
2132
+ const rows = this.db.query("SELECT * FROM sim_suites ORDER BY updated DESC").all();
2133
+ return rows.map((r) => ({
2134
+ id: r.id,
2135
+ name: r.name,
2136
+ packets: JSON.parse(r.packets),
2137
+ updated: r.updated
2138
+ }));
2139
+ }
2140
+ get(id) {
2141
+ const row = this.db.query("SELECT * FROM sim_suites WHERE id = $id").get({ $id: id });
2142
+ return row ? {
2143
+ id: row.id,
2144
+ name: row.name,
2145
+ packets: JSON.parse(row.packets),
2146
+ updated: row.updated
2147
+ } : null;
2148
+ }
2149
+ save(suite) {
2150
+ this.db.query(`INSERT OR REPLACE INTO sim_suites (id, name, packets, updated)
2151
+ VALUES ($id,$name,$packets,$updated)`).run({
2152
+ $id: suite.id,
2153
+ $name: suite.name,
2154
+ $packets: JSON.stringify(suite.packets),
2155
+ $updated: suite.updated
2156
+ });
2157
+ }
2158
+ }
2159
+ var storePromise3 = null;
2160
+ async function store2() {
2161
+ storePromise3 ??= (async () => {
2162
+ try {
2163
+ mkdirSync7(dirname6(DEFAULT_SNAPSHOT_DB), { recursive: true });
2164
+ } catch {}
2165
+ const { Database } = await import("bun:sqlite");
2166
+ return new SqliteSuiteStore(new Database(DEFAULT_SNAPSHOT_DB, { create: true }));
2167
+ })();
2168
+ return storePromise3;
2169
+ }
2170
+ async function listSuites() {
2171
+ try {
2172
+ return (await store2()).list();
2173
+ } catch (e) {
2174
+ logger.error(`sim suite read failed: ${e instanceof Error ? e.message : String(e)}`);
2175
+ return [];
2176
+ }
2177
+ }
2178
+ async function getSuite(id) {
2179
+ try {
2180
+ return (await store2()).get(id);
2181
+ } catch {
2182
+ return null;
2183
+ }
2184
+ }
2185
+ async function saveSuite(input) {
2186
+ const suite = {
2187
+ id: input.id ?? `suite_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2188
+ name: input.name,
2189
+ packets: input.packets,
2190
+ updated: Date.now()
2191
+ };
2192
+ try {
2193
+ (await store2()).save(suite);
2194
+ } catch (e) {
2195
+ logger.error(`sim suite write failed: ${e instanceof Error ? e.message : String(e)}`);
2196
+ }
2197
+ return suite;
2198
+ }
2199
+
2200
+ // src/observability/sim-routes.ts
2201
+ var JSON_HEADERS6 = { "content-type": "application/json; charset=utf-8" };
2202
+ function json6(body, status = 200) {
2203
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS6 });
2204
+ }
2205
+ async function bodyJson3(req) {
2206
+ return await req.json().catch(() => ({}));
2207
+ }
2208
+ var storePromise4 = null;
2209
+ function snapshots() {
2210
+ storePromise4 ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
2211
+ return storePromise4;
2212
+ }
2213
+ async function configFor(body) {
2214
+ if (body.configText)
2215
+ return { text: body.configText, source: "supplied text" };
2216
+ if (body.snapshotId) {
2217
+ const snapshot = (await snapshots()).get(body.snapshotId);
2218
+ if (!snapshot)
2219
+ return { error: `no snapshot '${body.snapshotId}'` };
2220
+ return { text: snapshot.body, source: `snapshot ${snapshot.id}` };
2221
+ }
2222
+ const device = body.device ?? getConfig().defaultDevice;
2223
+ const ctx = createContext(undefined, device);
2224
+ const text = await executeMikrotikCommand("/export terse", ctx);
2225
+ if (looksLikeError(text) || text.trim() === "") {
2226
+ return { error: `could not read the configuration of '${device}'` };
2227
+ }
2228
+ return { text, source: `live config of ${device}` };
2229
+ }
2230
+ async function simRoutes(req, url) {
2231
+ const p = url.pathname;
2232
+ if (!p.startsWith("/api/sim"))
2233
+ return null;
2234
+ if (p === "/api/sim/suites" && req.method === "GET") {
2235
+ return json6({ suites: await listSuites() });
2236
+ }
2237
+ if (p === "/api/sim/suites" && req.method === "POST") {
2238
+ const body = await bodyJson3(req);
2239
+ if (!body.name || !Array.isArray(body.packets) || body.packets.length === 0) {
2240
+ return json6({ error: "name and a non-empty packets array are required" }, 400);
2241
+ }
2242
+ const saved = await saveSuite({
2243
+ id: body.id,
2244
+ name: body.name,
2245
+ packets: body.packets
2246
+ });
2247
+ return json6({ suite: saved });
2248
+ }
2249
+ if (p === "/api/sim/packet" && req.method === "POST") {
2250
+ const body = await bodyJson3(req);
2251
+ const config = await configFor(body);
2252
+ if ("error" in config)
2253
+ return json6(config, 400);
2254
+ const model = buildModel(config.text);
2255
+ const result = tracePacket({ model, packet: body.packet });
2256
+ return json6({
2257
+ source: config.source,
2258
+ result,
2259
+ coverage: {
2260
+ unmodelled: model.unmodelled,
2261
+ unparsedLines: model.unparsedLines,
2262
+ dynamicRouteSources: model.dynamicRouteSources
2263
+ }
2264
+ });
2265
+ }
2266
+ if (p === "/api/sim/change" && req.method === "POST") {
2267
+ const body = await bodyJson3(req);
2268
+ const config = await configFor(body);
2269
+ if ("error" in config)
2270
+ return json6(config, 400);
2271
+ if (!body.changes || body.changes.trim() === "") {
2272
+ return json6({ error: "changes are required" }, 400);
2273
+ }
2274
+ const before = tracePacket({ model: buildModel(config.text), packet: body.packet });
2275
+ const afterModel = buildModel(`${config.text}
2276
+ ${body.changes}
2277
+ `);
2278
+ const after = tracePacket({ model: afterModel, packet: body.packet });
2279
+ return json6({
2280
+ source: config.source,
2281
+ before,
2282
+ after,
2283
+ diff: diffTraces(before, after),
2284
+ coverage: { unmodelled: afterModel.unmodelled, unparsedLines: afterModel.unparsedLines }
2285
+ });
2286
+ }
2287
+ if (p === "/api/sim/reachability" && req.method === "GET") {
2288
+ const config = await configFor({
2289
+ device: url.searchParams.get("device") ?? undefined,
2290
+ snapshotId: url.searchParams.get("snapshot") ?? undefined,
2291
+ packet: {}
2292
+ });
2293
+ if ("error" in config)
2294
+ return json6(config, 400);
2295
+ const model = buildModel(config.text);
2296
+ const dead = unreachableRules(model.filter);
2297
+ return json6({
2298
+ source: config.source,
2299
+ rules: model.filter.map((r) => ({
2300
+ chain: r.chain,
2301
+ index: r.index,
2302
+ action: r.action,
2303
+ line: r.line,
2304
+ raw: r.raw,
2305
+ disabled: r.disabled,
2306
+ unreachable: dead.some((d) => d.rule === r),
2307
+ shadowedBy: dead.find((d) => d.rule === r)?.shadowedBy.index,
2308
+ why: dead.find((d) => d.rule === r)?.why
2309
+ })),
2310
+ unreachableCount: dead.length
2311
+ });
2312
+ }
2313
+ const runMatch = p.startsWith("/api/sim/suite/") && p.endsWith("/run");
2314
+ if (runMatch && req.method === "POST") {
2315
+ const id = decodeURIComponent(p.slice("/api/sim/suite/".length, -"/run".length));
2316
+ const suite = await getSuite(id);
2317
+ if (!suite)
2318
+ return json6({ error: `no suite '${id}'` }, 404);
2319
+ const body = await bodyJson3(req);
2320
+ const config = await configFor({ ...body, packet: {} });
2321
+ if ("error" in config)
2322
+ return json6(config, 400);
2323
+ const model = buildModel(config.text);
2324
+ const results = suite.packets.map((entry) => {
2325
+ const result = tracePacket({ model, packet: entry.packet });
2326
+ return {
2327
+ name: entry.name,
2328
+ expect: entry.expect,
2329
+ verdict: result.verdict,
2330
+ ok: result.verdict === entry.expect,
2331
+ summary: result.summary
2332
+ };
2333
+ });
2334
+ return json6({
2335
+ source: config.source,
2336
+ suite: { id: suite.id, name: suite.name },
2337
+ results,
2338
+ passed: results.filter((r) => r.ok).length,
2339
+ total: results.length
2340
+ });
2341
+ }
2342
+ return json6({ error: "not found" }, 404);
2343
+ }
2344
+
2345
+ // src/observability/schedule-routes.ts
2346
+ var JSON_HEADERS7 = { "content-type": "application/json; charset=utf-8" };
2347
+ function json7(body, status = 200) {
2348
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS7 });
2349
+ }
2350
+ function latestPerDevice(runs) {
2351
+ const latest = new Map;
2352
+ for (const run of runs) {
2353
+ if (run.outcome !== "ok")
2354
+ continue;
2355
+ const key = run.device ?? "(fleet)";
2356
+ const seen = latest.get(key);
2357
+ if (!seen || run.startedAt > seen.startedAt)
2358
+ latest.set(key, run);
2359
+ }
2360
+ return [...latest.values()];
2361
+ }
2362
+ function jobRow(job, runs, now) {
2363
+ const current = latestPerDevice(runs);
2364
+ const findings = current.flatMap((r) => r.findings);
2365
+ return {
2366
+ ...job,
2367
+ cronText: describeCron(job.cron),
2368
+ nextRun: nextRunOf(job.id) ?? nextRun(job.cron, now),
2369
+ lastRun: runs[0] ? {
2370
+ startedAt: runs[0].startedAt,
2371
+ finishedAt: runs[0].finishedAt,
2372
+ outcome: runs[0].outcome,
2373
+ device: runs[0].device,
2374
+ error: runs[0].error
2375
+ } : null,
2376
+ posture: {
2377
+ total: findings.length,
2378
+ worst: worstSeverity(findings) ?? null,
2379
+ bySeverity: severityCounts(findings),
2380
+ devices: current.length
2381
+ },
2382
+ runCount: runs.length
2383
+ };
2384
+ }
2385
+ function regressionsFor(runs, jobId) {
2386
+ const byDevice = new Map;
2387
+ for (const run of runs) {
2388
+ if (run.outcome !== "ok")
2389
+ continue;
2390
+ const key = run.device ?? "(fleet)";
2391
+ const list = byDevice.get(key);
2392
+ if (list)
2393
+ list.push(run);
2394
+ else
2395
+ byDevice.set(key, [run]);
2396
+ }
2397
+ const out = [];
2398
+ for (const [device, list] of byDevice) {
2399
+ for (let i = 0;i < list.length - 1; i++) {
2400
+ const diff = diffFindings(list[i + 1].findings, list[i].findings);
2401
+ if (diff.added.length + diff.worsened.length + diff.resolved.length === 0)
2402
+ continue;
2403
+ out.push({
2404
+ jobId,
2405
+ device,
2406
+ at: list[i].startedAt,
2407
+ summary: describeDiff(diff),
2408
+ added: diff.added,
2409
+ worsened: diff.worsened,
2410
+ resolved: diff.resolved
2411
+ });
2412
+ }
2413
+ }
2414
+ return out.sort((a, b) => b.at - a.at);
2415
+ }
2416
+ async function scheduleRoutes(req, url) {
2417
+ const p = url.pathname;
2418
+ if (!p.startsWith("/api/schedules"))
2419
+ return null;
2420
+ let store3;
2421
+ try {
2422
+ store3 = await scheduleStore();
2423
+ } catch (e) {
2424
+ return json7({ error: e instanceof Error ? e.message : String(e), jobs: [] }, 503);
2425
+ }
2426
+ const now = Date.now();
2427
+ if (p === "/api/schedules" && req.method === "GET") {
2428
+ const jobs = store3.listJobs().map((job) => jobRow(job, store3.runs(job.id, 500), now));
2429
+ return json7({
2430
+ jobs,
2431
+ schedulable: schedulableTools().map((a) => ({ tool: a.tool, summary: a.summary }))
2432
+ });
2433
+ }
2434
+ if (p === "/api/schedules" && req.method === "POST") {
2435
+ const body = await req.json().catch(() => null);
2436
+ if (!body)
2437
+ return json7({ error: "invalid JSON body" }, 400);
2438
+ const parsed = JobSchema.safeParse(body);
2439
+ if (!parsed.success) {
2440
+ return json7({
2441
+ error: parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")
2442
+ }, 400);
2443
+ }
2444
+ if (!auditAdapter(parsed.data.tool)) {
2445
+ return json7({ error: `'${parsed.data.tool}' is not a schedulable auditor` }, 400);
2446
+ }
2447
+ await primeRiskIndex();
2448
+ const risk = liveExecutor().riskOf(parsed.data.tool);
2449
+ if (risk !== "READ") {
2450
+ return json7({ error: `'${parsed.data.tool}' is annotated ${risk}; only READ may be scheduled` }, 400);
2451
+ }
2452
+ const existing = store3.getJob(parsed.data.id);
2453
+ const job = { ...parsed.data, createdAt: existing?.createdAt ?? now };
2454
+ store3.saveJob(job);
2455
+ if (job.enabled)
2456
+ armJob(job, now);
2457
+ else
2458
+ forgetJob(job.id);
2459
+ return json7({ job: jobRow(job, store3.runs(job.id, 500), now), replaced: existing !== null });
2460
+ }
2461
+ if (p === "/api/schedules/timeline" && req.method === "GET") {
2462
+ const jobId = url.searchParams.get("job") ?? undefined;
2463
+ const device = url.searchParams.get("device") ?? undefined;
2464
+ const days = Number(url.searchParams.get("days") ?? 30);
2465
+ const since = now - days * 86400000;
2466
+ const runs = store3.runs(jobId, 2000).filter((r) => r.startedAt >= since && (!device || r.device === device));
2467
+ return json7({
2468
+ points: runs.map((r) => ({
2469
+ at: r.startedAt,
2470
+ jobId: r.jobId,
2471
+ device: r.device,
2472
+ outcome: r.outcome,
2473
+ total: r.findings.length,
2474
+ bySeverity: severityCounts(r.findings),
2475
+ added: r.added,
2476
+ worsened: r.worsened,
2477
+ resolved: r.resolved,
2478
+ durationMs: r.finishedAt - r.startedAt
2479
+ })).sort((a, b) => a.at - b.at)
2480
+ });
2481
+ }
2482
+ if (p === "/api/schedules/regressions" && req.method === "GET") {
2483
+ const jobId = url.searchParams.get("job") ?? undefined;
2484
+ const limit = Number(url.searchParams.get("limit") ?? 50);
2485
+ const jobs = jobId ? [jobId] : store3.listJobs().map((j) => j.id);
2486
+ const items = jobs.flatMap((id2) => regressionsFor(store3.runs(id2, 500), id2)).sort((a, b) => b.at - a.at).slice(0, limit);
2487
+ return json7({ regressions: items });
2488
+ }
2489
+ const parts = p.slice("/api/schedules/".length).split("/").filter(Boolean);
2490
+ if (parts.length === 0 || parts.length > 2)
2491
+ return json7({ error: "not found" }, 404);
2492
+ const id = decodeURIComponent(parts[0]);
2493
+ const verb = parts[1];
2494
+ if (!verb && req.method === "DELETE") {
2495
+ const job = store3.getJob(id);
2496
+ if (!job)
2497
+ return json7({ error: `no schedule '${id}'` }, 404);
2498
+ const runCount = store3.runs(id, 1e4).length;
2499
+ store3.removeJob(id);
2500
+ forgetJob(id);
2501
+ return json7({ removed: id, runsDiscarded: runCount });
2502
+ }
2503
+ if (verb === "run" && req.method === "POST") {
2504
+ const job = store3.getJob(id);
2505
+ if (!job)
2506
+ return json7({ error: `no schedule '${id}'` }, 404);
2507
+ await primeRiskIndex();
2508
+ logger.info(`schedule '${id}' run requested from the dashboard`);
2509
+ const result = await runJob(job, liveExecutor(), runnerOptions());
2510
+ return json7({
2511
+ ...result,
2512
+ job: jobRow(job, store3.runs(id, 500), Date.now())
2513
+ });
2514
+ }
2515
+ if (!verb && req.method === "GET") {
2516
+ const job = store3.getJob(id);
2517
+ if (!job)
2518
+ return json7({ error: `no schedule '${id}'` }, 404);
2519
+ const runs = store3.runs(id, 500);
2520
+ return json7({ job: jobRow(job, runs, now), runs, regressions: regressionsFor(runs, id) });
2521
+ }
2522
+ return json7({ error: "not found" }, 404);
2523
+ }
2524
+
2525
+ // src/observability/explain-routes.ts
2526
+ var JSON_HEADERS8 = { "content-type": "application/json; charset=utf-8" };
2527
+ function json8(body, status = 200) {
2528
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS8 });
2529
+ }
2530
+ var storePromise5 = null;
2531
+ function snapshots2() {
2532
+ storePromise5 ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
2533
+ return storePromise5;
2534
+ }
2535
+ async function configFor2(device, snapshotId) {
2536
+ if (snapshotId) {
2537
+ const snapshot = (await snapshots2()).get(snapshotId);
2538
+ if (!snapshot)
2539
+ return { error: `no snapshot '${snapshotId}'`, status: 404 };
2540
+ return {
2541
+ text: snapshot.body,
2542
+ device: snapshot.device ?? device,
2543
+ source: `snapshot ${snapshotId}`
2544
+ };
2545
+ }
2546
+ const name = resolveDeviceName(device);
2547
+ const body = await executeMikrotikCommand("/export", createContext(undefined, name));
2548
+ if (looksLikeError(body) || body.trim() === "") {
2549
+ return { error: body.trim() || "the device returned an empty export", status: 502 };
2550
+ }
2551
+ return { text: body, device: name, source: "live device" };
2552
+ }
2553
+ function payload(narrative, source, markdown) {
2554
+ return {
2555
+ narrative,
2556
+ markdown,
2557
+ mermaid: topologyMermaid(narrative),
2558
+ source
2559
+ };
2560
+ }
2561
+ async function explainRoutes(req, url) {
2562
+ const p = url.pathname;
2563
+ if (!p.startsWith("/api/explain"))
2564
+ return null;
2565
+ if (p === "/api/explain/diff" && req.method === "POST") {
2566
+ const body = await req.json().catch(() => null);
2567
+ if (!body)
2568
+ return json8({ error: "invalid JSON body" }, 400);
2569
+ if (!body.before && !body.beforeText) {
2570
+ return json8({ error: "a diff needs a baseline: pass `before` (a snapshot id) or `beforeText`" }, 400);
2571
+ }
2572
+ const device2 = body.device ?? "";
2573
+ const before = body.beforeText ? { text: body.beforeText, device: device2, source: "supplied text" } : await configFor2(device2, body.before ?? null);
2574
+ if ("error" in before)
2575
+ return json8({ error: `before: ${before.error}` }, before.status);
2576
+ const after = body.afterText ? { text: body.afterText, device: device2, source: "supplied text" } : await configFor2(device2, body.after ?? null);
2577
+ if ("error" in after)
2578
+ return json8({ error: `after: ${after.error}` }, after.status);
2579
+ const diff = diffNarratives(analyzeDevice(before.text, before.device), analyzeDevice(after.text, after.device));
2580
+ return json8({ diff, markdown: renderDiff(diff), before: before.source, after: after.source });
2581
+ }
2582
+ const rest = p.slice("/api/explain/".length).split("/").filter(Boolean);
2583
+ if (rest.length === 0)
2584
+ return json8({ error: "not found" }, 404);
2585
+ if (req.method !== "GET")
2586
+ return json8({ error: "not found" }, 404);
2587
+ const device = decodeURIComponent(rest[0]);
2588
+ const snapshotId = url.searchParams.get("snapshot");
2589
+ let config;
2590
+ try {
2591
+ config = await configFor2(device, snapshotId);
2592
+ } catch (e) {
2593
+ return json8({ error: e instanceof Error ? e.message : String(e) }, 502);
2594
+ }
2595
+ if ("error" in config)
2596
+ return json8({ error: config.error }, config.status);
2597
+ const narrative = { ...analyzeDevice(config.text, config.device), generatedAt: Date.now() };
2598
+ if (rest.length === 1) {
2599
+ return json8(payload(narrative, config.source, renderNarrative(narrative)));
2600
+ }
2601
+ if (rest[1] === "section" && rest[2]) {
2602
+ const section = decodeURIComponent(rest[2]);
2603
+ if (!NARRATIVE_SECTIONS.includes(section)) {
2604
+ return json8({ error: `unknown section '${section}'. Known: ${NARRATIVE_SECTIONS.join(", ")}` }, 400);
2605
+ }
2606
+ return json8(payload(narrative, config.source, renderNarrative(narrative, { sections: [section] })));
2607
+ }
2608
+ return json8({ error: "not found" }, 404);
2609
+ }
2610
+
2611
+ // src/observability/attack-routes.ts
2612
+ var JSON_HEADERS9 = { "content-type": "application/json; charset=utf-8" };
2613
+ function json9(body, status = 200) {
2614
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS9 });
2615
+ }
2616
+ async function attackRoutes(req, url) {
2617
+ const p = url.pathname;
2618
+ if (!p.startsWith("/api/attacks"))
2619
+ return null;
2620
+ let store3;
2621
+ try {
2622
+ store3 = await attackStore();
2623
+ } catch (e) {
2624
+ return json9({ error: e instanceof Error ? e.message : String(e), incidents: [] }, 503);
2625
+ }
2626
+ const cfg = getConfig().attacks;
2627
+ const now = Date.now();
2628
+ if (p === "/api/attacks" && req.method === "GET") {
2629
+ const hours = Number(url.searchParams.get("hours") ?? 24);
2630
+ const incidents = store3.listIncidents({
2631
+ since: now - hours * 3600000,
2632
+ limit: Number(url.searchParams.get("limit") ?? 200)
2633
+ });
2634
+ const responses = store3.listResponses({ active: true, limit: 500 });
2635
+ const blocked = new Set(responses.map((r) => r.source));
2636
+ return json9({
2637
+ incidents: incidents.map((i) => ({ ...i, blocked: blocked.has(i.source) })),
2638
+ responses,
2639
+ posture: {
2640
+ enabled: cfg.enabled,
2641
+ mode: cfg.mode,
2642
+ autoRespondTo: cfg.autoRespondTo,
2643
+ minConfidence: cfg.minConfidence
2644
+ }
2645
+ });
2646
+ }
2647
+ if (p === "/api/attacks/sources" && req.method === "GET") {
2648
+ const hours = Number(url.searchParams.get("hours") ?? 168);
2649
+ const incidents = store3.listIncidents({ since: now - hours * 3600000, limit: 1000 });
2650
+ const bySource = new Map;
2651
+ for (const i of incidents) {
2652
+ if (i.source === "")
2653
+ continue;
2654
+ const seen = bySource.get(i.source);
2655
+ if (seen) {
2656
+ for (const d of i.devices)
2657
+ seen.devices.add(d);
2658
+ for (const d of i.detectors)
2659
+ seen.detectors.add(d);
2660
+ seen.firstTs = Math.min(seen.firstTs, i.firstTs);
2661
+ seen.lastTs = Math.max(seen.lastTs, i.lastTs);
2662
+ seen.incidents++;
2663
+ } else {
2664
+ bySource.set(i.source, {
2665
+ source: i.source,
2666
+ devices: new Set(i.devices),
2667
+ detectors: new Set(i.detectors),
2668
+ firstTs: i.firstTs,
2669
+ lastTs: i.lastTs,
2670
+ incidents: 1,
2671
+ worst: i.severity
2672
+ });
2673
+ }
2674
+ }
2675
+ const active = new Set(store3.listResponses({ active: true, limit: 500 }).map((r) => r.source));
2676
+ return json9({
2677
+ sources: [...bySource.values()].map((s) => ({
2678
+ source: s.source,
2679
+ devices: [...s.devices].sort(),
2680
+ detectors: [...s.detectors].sort(),
2681
+ firstTs: s.firstTs,
2682
+ lastTs: s.lastTs,
2683
+ incidents: s.incidents,
2684
+ worst: s.worst,
2685
+ blocked: active.has(s.source),
2686
+ geo: getDeviceGeo(s.source)
2687
+ })).sort((a, b) => b.incidents - a.incidents || b.lastTs - a.lastTs)
2688
+ });
2689
+ }
2690
+ if (p === "/api/attacks/config" && req.method === "GET") {
2691
+ const guards = buildGuards();
2692
+ const never = [...neverBlockSet(guards)].sort();
2693
+ const check = url.searchParams.get("check");
2694
+ return json9({
2695
+ config: cfg,
2696
+ neverBlock: never,
2697
+ check: check ? { address: check, protected: isNeverBlock(check, new Set(never)) } : undefined
2698
+ });
2699
+ }
2700
+ if (p === "/api/attacks/config" && req.method === "POST") {
2701
+ return json9({
2702
+ error: "edit the `attacks` block in Config \u2014 the policy has to survive a restart, so it is not held in memory here"
2703
+ }, 501);
2704
+ }
2705
+ if (p === "/api/attacks/scan" && req.method === "POST") {
2706
+ const body = await req.json().catch(() => ({}));
2707
+ const scope = body.devices?.length ? body.devices.join(", ") : body.onlineOnly ? "reachable devices" : "every device";
2708
+ logger.info(`attack sweep requested from the dashboard (${scope})`);
2709
+ const result = await sweep({
2710
+ devices: body.devices?.length ? body.devices : undefined,
2711
+ onlineOnly: body.onlineOnly,
2712
+ respond: false
2713
+ });
2714
+ return json9({
2715
+ incidents: result.incidents,
2716
+ unavailable: result.unavailable,
2717
+ devices: result.devices,
2718
+ skipped: result.skipped
2719
+ });
2720
+ }
2721
+ if (p === "/api/attacks/devices" && req.method === "GET") {
2722
+ return json9({
2723
+ devices: listDevices().names.map((name) => ({
2724
+ name,
2725
+ reachable: getDeviceStatus(name).reachable
2726
+ }))
2727
+ });
2728
+ }
2729
+ const rest = p.slice("/api/attacks/".length).split("/").filter(Boolean);
2730
+ if (rest[0] === "responses" && rest[1] && req.method === "DELETE") {
2731
+ const address = decodeURIComponent(rest[1]);
2732
+ const response = store3.responseFor(address);
2733
+ const devices = response?.devices ?? [];
2734
+ if (devices.length === 0)
2735
+ return json9({ error: `no recorded block for ${address}` }, 404);
2736
+ const results = await revokeBlock(address, devices, response?.list);
2737
+ store3.revokeResponse(address, now);
2738
+ return json9({ address, results });
2739
+ }
2740
+ if (rest.length === 1 && req.method === "GET") {
2741
+ const incident = store3.getIncident(decodeURIComponent(rest[0]));
2742
+ if (!incident)
2743
+ return json9({ error: "not found" }, 404);
2744
+ return json9({ incident, response: store3.responseFor(incident.source) });
2745
+ }
2746
+ if (rest.length === 2 && rest[1] === "respond" && req.method === "POST") {
2747
+ const incident = store3.getIncident(decodeURIComponent(rest[0]));
2748
+ if (!incident)
2749
+ return json9({ error: "not found" }, 404);
2750
+ const body = await req.json().catch(() => ({}));
2751
+ if (body.action === "dismiss") {
2752
+ return json9({ dismissed: incident.id });
2753
+ }
2754
+ const decision = decide({
2755
+ incident,
2756
+ policy: policyFromConfig(),
2757
+ guards: buildGuards(),
2758
+ recentBlockCount: store3.countRecentBlocks(incident.devices, now - 3600000),
2759
+ manual: true,
2760
+ confirm: body.confirm ?? false,
2761
+ timeout: body.timeout
2762
+ });
2763
+ if (!isPlan(decision)) {
2764
+ return json9({ refused: true, guard: decision.guard, reason: decision.reason }, 200);
2765
+ }
2766
+ if (decision.action === "escalate") {
2767
+ return json9({ escalated: true, reason: decision.reason });
2768
+ }
2769
+ if (!body.confirm) {
2770
+ return json9({ dryRun: true, plan: decision });
2771
+ }
2772
+ const applied = await executePlan(decision);
2773
+ const ok = applied.some((r) => r.ok);
2774
+ store3.recordResponse({
2775
+ incidentId: incident.id,
2776
+ action: decision.action,
2777
+ source: decision.source,
2778
+ devices: decision.devices,
2779
+ timeout: decision.timeout,
2780
+ list: decision.list,
2781
+ reason: decision.reason,
2782
+ ts: now,
2783
+ ok,
2784
+ error: ok ? undefined : applied.map((r) => r.detail).join("; ")
2785
+ });
2786
+ return json9({ applied, ok, plan: decision });
2787
+ }
2788
+ return json9({ error: "not found" }, 404);
2789
+ }
2790
+
2791
+ // src/observability/alert-routes.ts
2792
+ var JSON_HEADERS10 = { "content-type": "application/json; charset=utf-8" };
2793
+ function json10(body, status = 200) {
2794
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS10 });
2795
+ }
2796
+ function ruleRow(rule, subject, status, since) {
2797
+ return {
2798
+ id: rule.id,
2799
+ subject,
2800
+ description: rule.description,
2801
+ when: rule.when,
2802
+ severity: rule.severity,
2803
+ channels: rule.channels,
2804
+ for: rule.for,
2805
+ cooldown: rule.cooldown,
2806
+ enabled: rule.enabled,
2807
+ mutedUntil: rule.mutedUntil,
2808
+ status,
2809
+ since,
2810
+ warnings: ruleWarnings(rule)
2811
+ };
2812
+ }
2813
+ function previewRule(rule, events) {
2814
+ if (!isEventTrigger(rule.when))
2815
+ return { fires: 0, resolves: 0, matched: 0 };
2816
+ let state = initialState(events[0]?.ts ?? 0);
2817
+ let fires = 0;
2818
+ let resolves = 0;
2819
+ let matched = 0;
2820
+ for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
2821
+ const ev = {
2822
+ kind: "tool_call",
2823
+ tool: e.tool,
2824
+ device: e.device,
2825
+ risk: e.risk,
2826
+ isError: e.isError
2827
+ };
2828
+ const met = eventMet(rule.when, ev);
2829
+ if (met)
2830
+ matched++;
2831
+ const res = step(rule, state, met, e.ts);
2832
+ state = res.state;
2833
+ if (res.action.type === "fire")
2834
+ fires++;
2835
+ if (res.action.type === "resolve")
2836
+ resolves++;
2837
+ }
2838
+ return { fires, resolves, matched };
2839
+ }
2840
+ async function alertRoutes(req, url, db) {
2841
+ const p = url.pathname;
2842
+ if (!p.startsWith("/api/alerts"))
2843
+ return null;
2844
+ const engine = getAlertEngine();
2845
+ const channels = getConfig().alerts?.channels;
2846
+ if (p === "/api/alerts" && req.method === "GET") {
2847
+ if (!engine) {
2848
+ return json10({ configured: false, rules: [], active: [], channels: {} });
2849
+ }
2850
+ const now = Date.now();
2851
+ const rows = engine.snapshot().map(({ rule, subject, state }) => ruleRow(rule, subject, isMuted(rule, now) ? "muted" : !rule.enabled ? "disabled" : state.status, state.since));
2852
+ return json10({
2853
+ configured: true,
2854
+ rules: rows,
2855
+ active: rows.filter((r) => r.status === "firing"),
2856
+ channels: redactChannels(channels)
2857
+ });
2858
+ }
2859
+ if (p === "/api/alerts/history" && req.method === "GET") {
2860
+ if (!engine)
2861
+ return json10({ history: [] });
2862
+ const hours = Number(url.searchParams.get("hours") ?? 24);
2863
+ const history = await engine.history({
2864
+ sinceMs: Date.now() - hours * 3600000,
2865
+ ruleId: url.searchParams.get("rule") ?? undefined,
2866
+ limit: Number(url.searchParams.get("limit") ?? 200)
2867
+ });
2868
+ return json10({ history });
2869
+ }
2870
+ if (p === "/api/alerts/rules" && req.method === "POST") {
2871
+ if (!engine)
2872
+ return json10({ error: "alerting is not configured" }, 400);
2873
+ const parsed = AlertRuleSchema.safeParse(await req.json());
2874
+ if (!parsed.success) {
2875
+ return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
2876
+ }
2877
+ const rules = engine.configuredRules();
2878
+ if (rules.some((r) => r.id === parsed.data.id)) {
2879
+ return json10({ error: `rule '${parsed.data.id}' already exists` }, 409);
2880
+ }
2881
+ engine.setRules([...rules, parsed.data]);
2882
+ return json10({ ok: true, id: parsed.data.id });
2883
+ }
2884
+ const idMatch = p.match(/^\/api\/alerts\/rules\/([^/]+)$/);
2885
+ if (idMatch) {
2886
+ if (!engine)
2887
+ return json10({ error: "alerting is not configured" }, 400);
2888
+ const id = decodeURIComponent(idMatch[1]);
2889
+ const rules = engine.configuredRules();
2890
+ const existing = rules.find((r) => r.id === id);
2891
+ if (!existing)
2892
+ return json10({ error: `unknown rule: ${id}` }, 404);
2893
+ if (req.method === "DELETE") {
2894
+ engine.setRules(rules.filter((r) => r.id !== id));
2895
+ return json10({ ok: true });
2896
+ }
2897
+ if (req.method === "PATCH") {
2898
+ const body = await req.json();
2899
+ let mutedUntil = existing.mutedUntil;
2900
+ if (typeof body.mute === "string") {
2901
+ const ms = parseDuration(body.mute);
2902
+ if (ms === null)
2903
+ return json10({ error: `invalid duration: ${body.mute}` }, 400);
2904
+ mutedUntil = ms === 0 ? undefined : Date.now() + ms;
2905
+ delete body.mute;
2906
+ }
2907
+ const merged = AlertRuleSchema.safeParse({ ...existing, ...body, mutedUntil });
2908
+ if (!merged.success) {
2909
+ return json10({ error: merged.error.issues.map((i) => i.message).join("; ") }, 400);
2910
+ }
2911
+ engine.setRules(rules.map((r) => r.id === id ? merged.data : r));
2912
+ return json10({ ok: true, rule: merged.data });
2913
+ }
2914
+ }
2915
+ if (p === "/api/alerts/test" && req.method === "POST") {
2916
+ const body = await req.json();
2917
+ if (!channels || !body?.channel)
2918
+ return json10({ error: "channel is required" }, 400);
2919
+ try {
2920
+ const result = await deliver(body.channel, channels, {
2921
+ ruleId: "test",
2922
+ severity: "low",
2923
+ kind: "fire",
2924
+ title: "Test notification from mikrotik-mcp",
2925
+ body: "If you can read this, this channel is configured correctly.",
2926
+ at: Date.now()
2927
+ });
2928
+ return json10(result);
2929
+ } catch (e) {
2930
+ logger.error(`Alert channel test failed: ${logError(e)}`);
2931
+ return json10({ error: clientError(e) }, 502);
2932
+ }
2933
+ }
2934
+ if (p === "/api/alerts/preview" && req.method === "POST") {
2935
+ const parsed = AlertRuleSchema.safeParse(await req.json());
2936
+ if (!parsed.success) {
2937
+ return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
2938
+ }
2939
+ const hours = Number(url.searchParams.get("hours") ?? 24);
2940
+ if (!db) {
2941
+ return json10({ hours, sampled: 0, fires: 0, resolves: 0, matched: 0, noHistory: true });
2942
+ }
2943
+ const events = db.query({ since: Date.now() - hours * 3600000, limit: 5000 }).map((e) => ({
2944
+ ts: e.ts,
2945
+ tool: e.tool,
2946
+ device: e.device,
2947
+ risk: e.risk,
2948
+ isError: e.isError
2949
+ }));
2950
+ return json10({ hours, sampled: events.length, ...previewRule(parsed.data, events) });
2951
+ }
2952
+ return null;
2953
+ }
2954
+
2955
+ // src/observability/memory-routes.ts
2956
+ var JSON_HEADERS11 = { "content-type": "application/json; charset=utf-8" };
2957
+ function json11(body, status = 200) {
2958
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS11 });
2959
+ }
2960
+ async function bodyJson4(req) {
1846
2961
  return await req.json();
1847
2962
  }
1848
2963
  async function memoryRoutes(req, url) {
@@ -1851,29 +2966,29 @@ async function memoryRoutes(req, url) {
1851
2966
  return null;
1852
2967
  const cfg = getConfig();
1853
2968
  if (!cfg.memory.enabled) {
1854
- return json2({ error: "Knowledge-graph memory is disabled" }, 503);
2969
+ return json11({ error: "Knowledge-graph memory is disabled" }, 503);
1855
2970
  }
1856
2971
  if (p === "/api/memory/config" && req.method === "GET") {
1857
2972
  let stats = null;
1858
2973
  try {
1859
- const store2 = await getMemoryStore();
1860
- stats = store2.stats();
2974
+ const store4 = await getMemoryStore();
2975
+ stats = store4.stats();
1861
2976
  } catch {}
1862
- return json2({
2977
+ return json11({
1863
2978
  enabled: cfg.memory.enabled,
1864
2979
  dbPath: cfg.memory.dbPath,
1865
2980
  stats
1866
2981
  });
1867
2982
  }
1868
2983
  if (p === "/api/memory/config" && req.method === "POST") {
1869
- const body = await bodyJson2(req);
2984
+ const body = await bodyJson4(req);
1870
2985
  const updates = {};
1871
2986
  if (body.dbPath !== undefined && typeof body.dbPath === "string" && body.dbPath !== cfg.memory.dbPath) {
1872
2987
  updates.dbPath = body.dbPath;
1873
2988
  try {
1874
2989
  await reopenMemoryStore(body.dbPath);
1875
2990
  } catch (e) {
1876
- return json2({
2991
+ return json11({
1877
2992
  error: `Failed to open memory DB at ${body.dbPath}: ${e instanceof Error ? e.message : String(e)}`
1878
2993
  }, 400);
1879
2994
  }
@@ -1892,79 +3007,79 @@ async function memoryRoutes(req, url) {
1892
3007
  }
1893
3008
  let stats = null;
1894
3009
  try {
1895
- const store2 = await getMemoryStore();
1896
- stats = store2.stats();
3010
+ const store4 = await getMemoryStore();
3011
+ stats = store4.stats();
1897
3012
  } catch {}
1898
- return json2({
3013
+ return json11({
1899
3014
  ok: true,
1900
3015
  enabled: getConfig().memory.enabled,
1901
3016
  dbPath: getConfig().memory.dbPath,
1902
3017
  stats
1903
3018
  });
1904
3019
  }
1905
- const store = await getMemoryStore();
3020
+ const store3 = await getMemoryStore();
1906
3021
  if (p === "/api/memory/graph" && req.method === "GET") {
1907
- return json2(store.readGraph());
3022
+ return json11(store3.readGraph());
1908
3023
  }
1909
3024
  if (p === "/api/memory/stats" && req.method === "GET") {
1910
- return json2(store.stats());
3025
+ return json11(store3.stats());
1911
3026
  }
1912
3027
  if (p === "/api/memory/search" && req.method === "GET") {
1913
3028
  const q = url.searchParams.get("q") || "";
1914
3029
  const limit = Number(url.searchParams.get("limit") ?? 50);
1915
- return json2(store.searchNodes(q, limit));
3030
+ return json11(store3.searchNodes(q, limit));
1916
3031
  }
1917
3032
  if (p === "/api/memory/activity" && req.method === "GET") {
1918
3033
  const limit = Number(url.searchParams.get("limit") ?? 50);
1919
3034
  const since = url.searchParams.get("since");
1920
- return json2(store.activity(limit, since ? Number(since) : undefined));
3035
+ return json11(store3.activity(limit, since ? Number(since) : undefined));
1921
3036
  }
1922
3037
  const entityMatch = p.match(/^\/api\/memory\/entity\/(.+)$/);
1923
3038
  if (entityMatch && req.method === "GET") {
1924
3039
  const name = decodeURIComponent(entityMatch[1]);
1925
- const graph = store.openNodes([name]);
3040
+ const graph = store3.openNodes([name]);
1926
3041
  if (graph.entities.length === 0)
1927
- return json2({ error: "Entity not found" }, 404);
1928
- return json2({ entity: graph.entities[0], relations: graph.relations });
3042
+ return json11({ error: "Entity not found" }, 404);
3043
+ return json11({ entity: graph.entities[0], relations: graph.relations });
1929
3044
  }
1930
3045
  if (p === "/api/memory/entities" && req.method === "POST") {
1931
- const body = await bodyJson2(req);
1932
- const created = store.createEntities(body.entities ?? []);
1933
- return json2({ created, count: created.length });
3046
+ const body = await bodyJson4(req);
3047
+ const created = store3.createEntities(body.entities ?? []);
3048
+ return json11({ created, count: created.length });
1934
3049
  }
1935
3050
  if (p === "/api/memory/relations" && req.method === "POST") {
1936
- const body = await bodyJson2(req);
1937
- const created = store.createRelations(body.relations ?? []);
1938
- return json2({ created, count: created.length });
3051
+ const body = await bodyJson4(req);
3052
+ const created = store3.createRelations(body.relations ?? []);
3053
+ return json11({ created, count: created.length });
1939
3054
  }
1940
3055
  if (p === "/api/memory/observations" && req.method === "POST") {
1941
- const body = await bodyJson2(req);
1942
- const results = store.addObservations(body.observations ?? []);
1943
- return json2({ results });
3056
+ const body = await bodyJson4(req);
3057
+ const results = store3.addObservations(body.observations ?? []);
3058
+ return json11({ results });
1944
3059
  }
1945
3060
  if (p === "/api/memory/entities" && req.method === "DELETE") {
1946
- const body = await bodyJson2(req);
1947
- const removed = store.deleteEntities(body.names ?? []);
1948
- return json2({ removed });
3061
+ const body = await bodyJson4(req);
3062
+ const removed = store3.deleteEntities(body.names ?? []);
3063
+ return json11({ removed });
1949
3064
  }
1950
3065
  if (p === "/api/memory/relations" && req.method === "DELETE") {
1951
- const body = await bodyJson2(req);
1952
- const removed = store.deleteRelations(body.relations ?? []);
1953
- return json2({ removed });
3066
+ const body = await bodyJson4(req);
3067
+ const removed = store3.deleteRelations(body.relations ?? []);
3068
+ return json11({ removed });
1954
3069
  }
1955
3070
  if (p === "/api/memory/observations" && req.method === "DELETE") {
1956
- const body = await bodyJson2(req);
1957
- const removed = store.deleteObservations(body.deletions ?? []);
1958
- return json2({ removed });
3071
+ const body = await bodyJson4(req);
3072
+ const removed = store3.deleteObservations(body.deletions ?? []);
3073
+ return json11({ removed });
1959
3074
  }
1960
3075
  return null;
1961
3076
  }
1962
3077
 
1963
3078
  // src/observability/dashboard.ts
1964
3079
  var SERVER_TAG3 = "mikrotik-mcp";
1965
- var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
1966
- function json3(body, status = 200) {
1967
- return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS3 });
3080
+ var JSON_HEADERS12 = { "content-type": "application/json; charset=utf-8" };
3081
+ function json12(body, status = 200) {
3082
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS12 });
1968
3083
  }
1969
3084
  function restartProcess() {
1970
3085
  let ok = false;
@@ -1992,7 +3107,7 @@ function runUpgrade(spec) {
1992
3107
  env: process.env,
1993
3108
  stdio: ["ignore", "pipe", "pipe"]
1994
3109
  });
1995
- const timer5 = setTimeout(() => {
3110
+ const timer4 = setTimeout(() => {
1996
3111
  child.kill();
1997
3112
  resolve({ ok: false, log: `${out}
1998
3113
  (timed out after 180s)` });
@@ -2000,12 +3115,12 @@ function runUpgrade(spec) {
2000
3115
  child.stdout?.on("data", (d) => out += d.toString());
2001
3116
  child.stderr?.on("data", (d) => out += d.toString());
2002
3117
  child.on("error", (e) => {
2003
- clearTimeout(timer5);
3118
+ clearTimeout(timer4);
2004
3119
  resolve({ ok: false, log: `${out}
2005
3120
  spawn error: ${String(e)}` });
2006
3121
  });
2007
3122
  child.on("exit", (code) => {
2008
- clearTimeout(timer5);
3123
+ clearTimeout(timer4);
2009
3124
  resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
2010
3125
  });
2011
3126
  });
@@ -2039,8 +3154,8 @@ function filterFromQuery(url) {
2039
3154
  until: num("until")
2040
3155
  };
2041
3156
  }
2042
- function facets(store) {
2043
- const recent = store.query({ limit: 5000 });
3157
+ function facets(store3) {
3158
+ const recent = store3.query({ limit: 5000 });
2044
3159
  const tools = new Set;
2045
3160
  const devices = new Set;
2046
3161
  for (const e of recent) {
@@ -2053,8 +3168,8 @@ function facets(store) {
2053
3168
  devices: [...devices].sort()
2054
3169
  };
2055
3170
  }
2056
- function deviceActivity(store) {
2057
- const recent = store.query({ limit: 5000 });
3171
+ function deviceActivity(store3) {
3172
+ const recent = store3.query({ limit: 5000 });
2058
3173
  const map = new Map;
2059
3174
  for (const e of recent) {
2060
3175
  if (!e.device)
@@ -2083,9 +3198,9 @@ function deviceActivity(store) {
2083
3198
  }
2084
3199
  return out;
2085
3200
  }
2086
- function devicesPayload(store) {
3201
+ function devicesPayload(store3) {
2087
3202
  const cfg = getConfig();
2088
- const activity = deviceActivity(store);
3203
+ const activity = deviceActivity(store3);
2089
3204
  const poolEnabled = isPoolEnabled();
2090
3205
  const poolMap = new Map(poolStatus().map((p) => [p.device, p]));
2091
3206
  const devices = Object.entries(cfg.devices).map(([name, dc]) => {
@@ -2096,7 +3211,8 @@ function devicesPayload(store) {
2096
3211
  host: dc.host,
2097
3212
  port: dc.port,
2098
3213
  mac: dc.mac,
2099
- transport: dc.mac ? "mac-telnet" : "ssh",
3214
+ transport: dc.mac ? "mac-telnet" : dc.api ? "rest" : "ssh",
3215
+ restPort: dc.api ? dc.apiPort ?? 443 : undefined,
2100
3216
  address: dc.mac ? dc.mac : `${dc.host}:${dc.port}`,
2101
3217
  username: dc.username,
2102
3218
  authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
@@ -2258,11 +3374,11 @@ function issues(error) {
2258
3374
  async function configRoutes(req, url, admin) {
2259
3375
  const p = url.pathname;
2260
3376
  if (p === "/api/config-schema" && req.method === "GET") {
2261
- return json3(configSchemaJson());
3377
+ return json12(configSchemaJson());
2262
3378
  }
2263
3379
  if (p === "/api/config/validate" && req.method === "POST") {
2264
3380
  const merged = mergeSecrets(await readJson(req), getConfig());
2265
- return json3(validateConfig(merged));
3381
+ return json12(validateConfig(merged));
2266
3382
  }
2267
3383
  if (p === "/api/config/test-device" && req.method === "POST") {
2268
3384
  const body = await readJson(req);
@@ -2271,22 +3387,22 @@ async function configRoutes(req, url, admin) {
2271
3387
  const merged = mergeSecrets(body?.config, currentDc);
2272
3388
  const parsed = DeviceConfigSchema.safeParse(merged);
2273
3389
  if (!parsed.success)
2274
- return json3({ ok: false, errors: issues(parsed.error) }, 400);
3390
+ return json12({ ok: false, errors: issues(parsed.error) }, 400);
2275
3391
  const status = await probeDevice(`config-test:${name}`, parsed.data);
2276
- return json3({ ok: true, status });
3392
+ return json12({ ok: true, status });
2277
3393
  }
2278
3394
  if (p === "/api/config/preview" && req.method === "POST") {
2279
3395
  const before = JSON.stringify(redact(getConfig()), null, 2);
2280
3396
  const after = JSON.stringify(await readJson(req) ?? {}, null, 2);
2281
3397
  const d = diffLines(before, after, { fromLabel: "current", toLabel: "edited" });
2282
- return json3({ summary: d.summary, unified: d.unified });
3398
+ return json12({ summary: d.summary, unified: d.unified });
2283
3399
  }
2284
3400
  if (p === "/api/config" && req.method === "POST") {
2285
3401
  const body = await readJson(req);
2286
3402
  const merged = mergeSecrets(body?.config, getConfig());
2287
3403
  const v = validateConfig(merged);
2288
3404
  if (!v.ok || !v.value)
2289
- return json3({ ok: false, errors: v.errors }, 400);
3405
+ return json12({ ok: false, errors: v.errors }, 400);
2290
3406
  const prev = getConfig();
2291
3407
  const before = JSON.stringify(redact(prev), null, 2);
2292
3408
  const after = JSON.stringify(redact(v.value), null, 2);
@@ -2295,25 +3411,25 @@ async function configRoutes(req, url, admin) {
2295
3411
  const nextDevs = Object.keys(v.value.devices);
2296
3412
  const devicesChanged = prevDevs.length !== nextDevs.length || prevDevs.some((d) => !nextDevs.includes(d));
2297
3413
  const res = admin.applyConfig(v.value, clampRollback(body?.rollbackMs));
2298
- return json3({ ok: true, ...res, devicesChanged, summary: diff.summary, unified: diff.unified });
3414
+ return json12({ ok: true, ...res, devicesChanged, summary: diff.summary, unified: diff.unified });
2299
3415
  }
2300
3416
  if (p === "/api/config/keep" && req.method === "POST") {
2301
3417
  const body = await readJson(req);
2302
3418
  const kept = admin.keepConfig(String(body?.pendingId ?? ""));
2303
3419
  if (kept)
2304
3420
  recordVersion(getConfig(), "auto", Date.now());
2305
- return json3({ kept });
3421
+ return json12({ kept });
2306
3422
  }
2307
3423
  if (p === "/api/config/rollback" && req.method === "POST") {
2308
3424
  const body = await readJson(req);
2309
3425
  const rolledBack = admin.rollback(String(body?.pendingId ?? ""));
2310
- return json3({ rolledBack, config: redact(getConfig()) });
3426
+ return json12({ rolledBack, config: redact(getConfig()) });
2311
3427
  }
2312
3428
  if (p === "/api/reload" && req.method === "POST") {
2313
3429
  const body = await readJson(req);
2314
3430
  if (body?.hard === true) {
2315
3431
  const relaunched = restartProcess();
2316
- return json3({
3432
+ return json12({
2317
3433
  ok: true,
2318
3434
  mode: "restart",
2319
3435
  note: relaunched ? "Restarting now \u2014 a fresh server process was launched and will bind the same port(s) in ~1.5s. Reconnect shortly." : "Could not self-relaunch; the process is exiting. It only comes back if a supervisor respawns it."
@@ -2324,10 +3440,10 @@ async function configRoutes(req, url, admin) {
2324
3440
  setConfig(cfg);
2325
3441
  const devices = Object.keys(cfg.devices);
2326
3442
  logger.info(`Config reloaded from source \u2014 ${devices.length} device(s): ${devices.join(", ")}`);
2327
- return json3({ ok: true, mode: "config", count: devices.length, devices });
3443
+ return json12({ ok: true, mode: "config", count: devices.length, devices });
2328
3444
  } catch (e) {
2329
3445
  logger.error(`Config reload failed: ${logError(e)}`);
2330
- return json3({ ok: false, error: clientError(e) }, 500);
3446
+ return json12({ ok: false, error: clientError(e) }, 500);
2331
3447
  }
2332
3448
  }
2333
3449
  if (p === "/api/config/history" && req.method === "GET") {
@@ -2343,55 +3459,55 @@ async function configRoutes(req, url, admin) {
2343
3459
  } catch {}
2344
3460
  return { ...v, drift: { added, removed } };
2345
3461
  });
2346
- return json3({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
3462
+ return json12({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
2347
3463
  }
2348
3464
  if (p === "/api/config/history/get" && req.method === "GET") {
2349
3465
  const id = url.searchParams.get("id");
2350
3466
  if (!id)
2351
- return json3({ error: "id required" }, 400);
3467
+ return json12({ error: "id required" }, 400);
2352
3468
  try {
2353
3469
  const v = readVersion(id);
2354
- return json3({
3470
+ return json12({
2355
3471
  ts: v.ts,
2356
3472
  kind: v.kind,
2357
3473
  label: v.label,
2358
3474
  config: JSON.stringify(redact(v.config), null, 2)
2359
3475
  });
2360
3476
  } catch {
2361
- return json3({ error: "not found" }, 404);
3477
+ return json12({ error: "not found" }, 404);
2362
3478
  }
2363
3479
  }
2364
3480
  if (p === "/api/config/history/diff" && req.method === "GET") {
2365
3481
  const id = url.searchParams.get("id");
2366
3482
  if (!id)
2367
- return json3({ error: "id required" }, 400);
3483
+ return json12({ error: "id required" }, 400);
2368
3484
  try {
2369
3485
  const before = JSON.stringify(redact(readVersion(id).config), null, 2);
2370
3486
  const after = JSON.stringify(redact(getConfig()), null, 2);
2371
3487
  const d = diffLines(before, after, { fromLabel: "this version", toLabel: "current" });
2372
- return json3({ summary: d.summary, unified: d.unified });
3488
+ return json12({ summary: d.summary, unified: d.unified });
2373
3489
  } catch {
2374
- return json3({ error: "not found" }, 404);
3490
+ return json12({ error: "not found" }, 404);
2375
3491
  }
2376
3492
  }
2377
3493
  if (p === "/api/config/history/checkpoint" && req.method === "POST") {
2378
3494
  const b = await readJson(req);
2379
3495
  const label = b?.label?.trim() || "checkpoint";
2380
- return json3({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
3496
+ return json12({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
2381
3497
  }
2382
3498
  if (p === "/api/config/history/restore" && req.method === "POST") {
2383
3499
  const b = await readJson(req);
2384
3500
  if (!b?.id)
2385
- return json3({ error: "id required" }, 400);
3501
+ return json12({ error: "id required" }, 400);
2386
3502
  let target;
2387
3503
  try {
2388
3504
  target = readVersion(b.id);
2389
3505
  } catch {
2390
- return json3({ error: "not found" }, 404);
3506
+ return json12({ error: "not found" }, 404);
2391
3507
  }
2392
3508
  const v = validateConfig(target.config);
2393
3509
  if (!v.ok || !v.value)
2394
- return json3({ ok: false, errors: v.errors }, 400);
3510
+ return json12({ ok: false, errors: v.errors }, 400);
2395
3511
  recordVersion(getConfig(), "auto", Date.now(), "before restore");
2396
3512
  setConfig(v.value);
2397
3513
  let persisted = true;
@@ -2402,13 +3518,13 @@ async function configRoutes(req, url, admin) {
2402
3518
  }
2403
3519
  const label = target.label ? `restored "${target.label}"` : `restored ${b.id}`;
2404
3520
  recordVersion(getConfig(), "auto", Date.now(), label);
2405
- return json3({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
3521
+ return json12({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
2406
3522
  }
2407
3523
  if (p === "/api/config/history/delete" && req.method === "POST") {
2408
3524
  const b = await readJson(req);
2409
3525
  if (!b?.id)
2410
- return json3({ error: "id required" }, 400);
2411
- return deleteVersion(b.id) ? json3({ ok: true }) : json3({ error: "not found" }, 404);
3526
+ return json12({ error: "id required" }, 400);
3527
+ return deleteVersion(b.id) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
2412
3528
  }
2413
3529
  return null;
2414
3530
  }
@@ -2419,7 +3535,7 @@ async function modulesRoutes(req, url) {
2419
3535
  if (p === "/api/modules" && req.method === "GET") {
2420
3536
  const cfg = getConfig();
2421
3537
  const src = getConfigSource();
2422
- return json3({
3538
+ return json12({
2423
3539
  ...moduleSurface(cfg.tools),
2424
3540
  filter: cfg.tools,
2425
3541
  source: src,
@@ -2429,11 +3545,11 @@ async function modulesRoutes(req, url) {
2429
3545
  if (p === "/api/modules/toggle" && req.method === "POST") {
2430
3546
  const b = await readJson(req);
2431
3547
  if (typeof b?.slug !== "string" || typeof b?.enabled !== "boolean") {
2432
- return json3({ error: "slug (string) and enabled (boolean) are required" }, 400);
3548
+ return json12({ error: "slug (string) and enabled (boolean) are required" }, 400);
2433
3549
  }
2434
3550
  const mod = moduleCatalog.find((m) => m.slug.toLowerCase() === b.slug.toLowerCase());
2435
3551
  if (!mod)
2436
- return json3({ error: `unknown module: ${b.slug}` }, 404);
3552
+ return json12({ error: `unknown module: ${b.slug}` }, 404);
2437
3553
  const cfg = getConfig();
2438
3554
  const nextTools = ToolFilterSchema.parse(applyModuleToggle(cfg.tools, mod.slug, b.enabled));
2439
3555
  const next = { ...cfg, tools: nextTools };
@@ -2449,7 +3565,7 @@ async function modulesRoutes(req, url) {
2449
3565
  if (persisted) {
2450
3566
  recordVersion(getConfig(), "auto", Date.now(), `module ${b.enabled ? "enabled" : "disabled"}: ${mod.slug}`);
2451
3567
  }
2452
- return json3({
3568
+ return json12({
2453
3569
  ok: true,
2454
3570
  persisted,
2455
3571
  requiresReconnect: true,
@@ -2464,7 +3580,7 @@ async function modulesRoutes(req, url) {
2464
3580
  if (p === "/api/modules/app-views" && req.method === "POST") {
2465
3581
  const b = await readJson(req);
2466
3582
  if (typeof b?.enabled !== "boolean") {
2467
- return json3({ error: "enabled (boolean) is required" }, 400);
3583
+ return json12({ error: "enabled (boolean) is required" }, 400);
2468
3584
  }
2469
3585
  const cfg = getConfig();
2470
3586
  const next = { ...cfg, mcp: { ...cfg.mcp, appViews: b.enabled } };
@@ -2480,7 +3596,7 @@ async function modulesRoutes(req, url) {
2480
3596
  if (persisted) {
2481
3597
  recordVersion(getConfig(), "auto", Date.now(), `app views ${b.enabled ? "enabled" : "disabled"}`);
2482
3598
  }
2483
- return json3({
3599
+ return json12({
2484
3600
  ok: true,
2485
3601
  persisted,
2486
3602
  requiresReconnect: true,
@@ -2494,11 +3610,11 @@ async function modulesRoutes(req, url) {
2494
3610
  async function captureRoutes(req, url) {
2495
3611
  const p = url.pathname;
2496
3612
  if (p === "/api/capture/status" && req.method === "GET") {
2497
- return json3(capture.stats());
3613
+ return json12(capture.stats());
2498
3614
  }
2499
3615
  if (p === "/api/capture/packets" && req.method === "GET") {
2500
3616
  const limit = Number(url.searchParams.get("limit") ?? 200) || 200;
2501
- return json3({ packets: capture.recent(limit), stats: capture.stats() });
3617
+ return json12({ packets: capture.recent(limit), stats: capture.stats() });
2502
3618
  }
2503
3619
  if (p === "/api/capture/pcap" && req.method === "GET") {
2504
3620
  return new Response(capture.pcap(), {
@@ -2510,11 +3626,11 @@ async function captureRoutes(req, url) {
2510
3626
  }
2511
3627
  if (p === "/api/capture/start" && req.method === "POST") {
2512
3628
  const body = await readJson(req);
2513
- return json3(await capture.start(typeof body?.port === "number" ? body.port : DEFAULT_TZSP_PORT));
3629
+ return json12(await capture.start(typeof body?.port === "number" ? body.port : DEFAULT_TZSP_PORT));
2514
3630
  }
2515
3631
  if (p === "/api/capture/stop" && req.method === "POST") {
2516
3632
  capture.stop();
2517
- return json3({ ok: true, ...capture.stats() });
3633
+ return json12({ ok: true, ...capture.stats() });
2518
3634
  }
2519
3635
  return null;
2520
3636
  }
@@ -2526,24 +3642,24 @@ async function capsmanRoutes(req, url) {
2526
3642
  if (req.method === "GET") {
2527
3643
  const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
2528
3644
  if (p === "/api/capsman/overview") {
2529
- return json3(capsmanOverview(await fetchCapsmanState(ctx)));
3645
+ return json12(capsmanOverview(await fetchCapsmanState(ctx)));
2530
3646
  }
2531
3647
  if (p === "/api/capsman/clients") {
2532
3648
  const state = await fetchCapsmanState(ctx);
2533
3649
  const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
2534
3650
  const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
2535
- return json3({ clients: state.clients, weak });
3651
+ return json12({ clients: state.clients, weak });
2536
3652
  }
2537
3653
  if (p === "/api/capsman/audit") {
2538
- return json3(runCapsmanAudit(await fetchCapsmanState(ctx)));
3654
+ return json12(runCapsmanAudit(await fetchCapsmanState(ctx)));
2539
3655
  }
2540
3656
  if (p === "/api/capsman/trends") {
2541
3657
  if (!capsmanStore)
2542
- return json3({ error: "capsman store not active" }, 503);
3658
+ return json12({ error: "capsman store not active" }, 503);
2543
3659
  const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2544
3660
  const days = daysParam(url, 7, 30);
2545
3661
  const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
2546
- return json3({ series, days });
3662
+ return json12({ series, days });
2547
3663
  }
2548
3664
  return null;
2549
3665
  }
@@ -2554,45 +3670,45 @@ async function capsmanRoutes(req, url) {
2554
3670
  if (p === "/api/capsman/apply/steer") {
2555
3671
  const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
2556
3672
  if (!client)
2557
- return json3({ ok: false, error: "client not associated" }, 400);
3673
+ return json12({ ok: false, error: "client not associated" }, 400);
2558
3674
  if (steerAlreadyPresent(state, body.mac ?? "")) {
2559
- return json3({ ok: true, message: "already steered (no-op)" });
3675
+ return json12({ ok: true, message: "already steered (no-op)" });
2560
3676
  }
2561
3677
  const mode = body.mode === "soft" ? "soft" : "hard";
2562
3678
  const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
2563
3679
  if (!body.confirm)
2564
- return json3({ ok: true, preview: commands });
2565
- return json3(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
3680
+ return json12({ ok: true, preview: commands });
3681
+ return json12(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
2566
3682
  }
2567
3683
  if (p === "/api/capsman/apply/load-balance") {
2568
3684
  const plan = loadBalancePlan(state);
2569
3685
  const commands = buildLoadBalanceCommands(state, plan);
2570
3686
  if (!body.confirm)
2571
- return json3({ ok: true, preview: commands, plan });
2572
- return json3(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
3687
+ return json12({ ok: true, preview: commands, plan });
3688
+ return json12(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
2573
3689
  }
2574
3690
  if (p === "/api/capsman/apply/channel-plan") {
2575
3691
  if (state.path === "/caps-man") {
2576
- return json3({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
3692
+ return json12({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
2577
3693
  }
2578
3694
  const commands = buildChannelPlanCommands(state);
2579
3695
  if (!body.confirm)
2580
- return json3({ ok: true, preview: commands });
2581
- return json3(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
3696
+ return json12({ ok: true, preview: commands });
3697
+ return json12(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
2582
3698
  }
2583
3699
  if (p === "/api/capsman/apply/ft") {
2584
3700
  const commands = buildFtCommands(state);
2585
3701
  if (!body.confirm)
2586
- return json3({ ok: true, preview: commands });
2587
- return json3(await applyCapsmanWrites(ctx, commands, "pre-ft"));
3702
+ return json12({ ok: true, preview: commands });
3703
+ return json12(await applyCapsmanWrites(ctx, commands, "pre-ft"));
2588
3704
  }
2589
3705
  if (p === "/api/capsman/apply/ha") {
2590
3706
  const commands = buildHaCommands(state);
2591
3707
  const guidance = haGuidance(state);
2592
3708
  if (!body.confirm)
2593
- return json3({ ok: true, preview: commands, guidance });
3709
+ return json12({ ok: true, preview: commands, guidance });
2594
3710
  const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
2595
- return json3({ ...res, guidance });
3711
+ return json12({ ...res, guidance });
2596
3712
  }
2597
3713
  }
2598
3714
  return null;
@@ -2618,32 +3734,32 @@ async function clientsRoutes(req, url) {
2618
3734
  const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
2619
3735
  if (p === "/api/clients" && req.method === "GET") {
2620
3736
  const ctx = createContext(undefined, deviceFromQuery());
2621
- return json3(devicesView(await fetchDevices(ctx)));
3737
+ return json12(devicesView(await fetchDevices(ctx)));
2622
3738
  }
2623
3739
  if (p === "/api/clients/traffic" && req.method === "GET") {
2624
3740
  const ip = url.searchParams.get("ip");
2625
3741
  if (!ip)
2626
- return json3({ error: "ip required" }, 400);
3742
+ return json12({ error: "ip required" }, 400);
2627
3743
  const ctx = createContext(undefined, deviceFromQuery());
2628
- return json3(await sampleDeviceTraffic(ctx, ip));
3744
+ return json12(await sampleDeviceTraffic(ctx, ip));
2629
3745
  }
2630
3746
  if (p === "/api/clients/traffic-bulk" && req.method === "GET") {
2631
3747
  const ctx = createContext(undefined, deviceFromQuery());
2632
- return json3(await sampleAllTraffic(ctx));
3748
+ return json12(await sampleAllTraffic(ctx));
2633
3749
  }
2634
3750
  if (req.method === "POST") {
2635
3751
  const b = await readJson(req);
2636
3752
  const ctx = createContext(undefined, b?.device);
2637
3753
  if (p === "/api/clients/limits") {
2638
3754
  if (!b?.ip)
2639
- return json3({ error: "ip required" }, 400);
2640
- return json3(await setDeviceLimits(ctx, b.ip, { download: b.download, upload: b.upload }));
3755
+ return json12({ error: "ip required" }, 400);
3756
+ return json12(await setDeviceLimits(ctx, b.ip, { download: b.download, upload: b.upload }));
2641
3757
  }
2642
3758
  if (!b?.mac)
2643
- return json3({ error: "mac required" }, 400);
3759
+ return json12({ error: "mac required" }, 400);
2644
3760
  const run = async (op) => {
2645
3761
  const r = await op;
2646
- return json3({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
3762
+ return json12({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
2647
3763
  };
2648
3764
  if (p === "/api/clients/block")
2649
3765
  return run(blockDevice(ctx, b.mac, b.comment));
@@ -2655,12 +3771,12 @@ async function clientsRoutes(req, url) {
2655
3771
  return run(removeDeviceLease(ctx, b.mac));
2656
3772
  if (p === "/api/clients/set-ip") {
2657
3773
  if (!b.ip)
2658
- return json3({ error: "ip required" }, 400);
3774
+ return json12({ error: "ip required" }, 400);
2659
3775
  return run(setDeviceIp(ctx, b.mac, b.ip));
2660
3776
  }
2661
3777
  if (p === "/api/clients/label") {
2662
3778
  if (typeof b.label !== "string")
2663
- return json3({ error: "label required" }, 400);
3779
+ return json12({ error: "label required" }, 400);
2664
3780
  return run(setDeviceLabel(ctx, b.mac, b.label));
2665
3781
  }
2666
3782
  }
@@ -2673,20 +3789,20 @@ async function aaaRoutes(req, url) {
2673
3789
  const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
2674
3790
  if (req.method === "GET") {
2675
3791
  if (p === "/api/aaa/entities") {
2676
- return json3({ entities: AAA_ENTITIES });
3792
+ return json12({ entities: AAA_ENTITIES });
2677
3793
  }
2678
3794
  if (p === "/api/aaa/radius-incoming") {
2679
- return json3(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
3795
+ return json12(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
2680
3796
  }
2681
3797
  if (p === "/api/aaa/um-settings") {
2682
- return json3(await getUmSettings(createContext(undefined, deviceFromQuery())));
3798
+ return json12(await getUmSettings(createContext(undefined, deviceFromQuery())));
2683
3799
  }
2684
3800
  const listMatch = p.match(/^\/api\/aaa\/list\/([\w-]+)$/);
2685
3801
  if (listMatch) {
2686
3802
  const slug = listMatch[1];
2687
3803
  if (!AAA_ENTITIES[slug])
2688
- return json3({ error: "unknown entity" }, 404);
2689
- return json3(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
3804
+ return json12({ error: "unknown entity" }, 404);
3805
+ return json12(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
2690
3806
  }
2691
3807
  return null;
2692
3808
  }
@@ -2694,30 +3810,30 @@ async function aaaRoutes(req, url) {
2694
3810
  const body = await readJson(req);
2695
3811
  const ctx = createContext(undefined, body?.device);
2696
3812
  if (p === "/api/aaa/radius-incoming")
2697
- return json3(await setRadiusIncoming(ctx, body?.fields ?? {}));
3813
+ return json12(await setRadiusIncoming(ctx, body?.fields ?? {}));
2698
3814
  if (p === "/api/aaa/um-settings")
2699
- return json3(await setUmSettings(ctx, body?.fields ?? {}));
3815
+ return json12(await setUmSettings(ctx, body?.fields ?? {}));
2700
3816
  if (p === "/api/aaa/radius-reset-counters")
2701
- return json3(await resetRadiusCounters(ctx));
3817
+ return json12(await resetRadiusCounters(ctx));
2702
3818
  const slug = body?.slug ?? "";
2703
3819
  if (!AAA_ENTITIES[slug])
2704
- return json3({ error: "unknown entity" }, 404);
3820
+ return json12({ error: "unknown entity" }, 404);
2705
3821
  if (p === "/api/aaa/add")
2706
- return json3(await addAaaEntity(ctx, slug, body?.fields ?? {}));
3822
+ return json12(await addAaaEntity(ctx, slug, body?.fields ?? {}));
2707
3823
  if (p === "/api/aaa/update") {
2708
3824
  if (!body?.id)
2709
- return json3({ error: "id required" }, 400);
2710
- return json3(await updateAaaEntity(ctx, slug, body.id, body?.fields ?? {}));
3825
+ return json12({ error: "id required" }, 400);
3826
+ return json12(await updateAaaEntity(ctx, slug, body.id, body?.fields ?? {}));
2711
3827
  }
2712
3828
  if (p === "/api/aaa/remove") {
2713
3829
  if (!body?.id)
2714
- return json3({ error: "id required" }, 400);
2715
- return json3(await removeAaaEntity(ctx, slug, body.id));
3830
+ return json12({ error: "id required" }, 400);
3831
+ return json12(await removeAaaEntity(ctx, slug, body.id));
2716
3832
  }
2717
3833
  if (p === "/api/aaa/toggle") {
2718
3834
  if (!body?.id)
2719
- return json3({ error: "id required" }, 400);
2720
- return json3(await toggleAaaEntity(ctx, slug, body.id, body?.enable === true));
3835
+ return json12({ error: "id required" }, 400);
3836
+ return json12(await toggleAaaEntity(ctx, slug, body.id, body?.enable === true));
2721
3837
  }
2722
3838
  }
2723
3839
  return null;
@@ -2733,43 +3849,43 @@ async function usageRoutes(req, url) {
2733
3849
  return null;
2734
3850
  if (p === "/api/usage/sampler") {
2735
3851
  if (req.method === "GET")
2736
- return json3({ intervalMs: getUsageSamplerInterval() });
3852
+ return json12({ intervalMs: getUsageSamplerInterval() });
2737
3853
  if (req.method === "POST") {
2738
3854
  const b = await readJson(req);
2739
3855
  const applied = setUsageSamplerInterval(Number(b?.intervalMs));
2740
- return json3({ intervalMs: applied });
3856
+ return json12({ intervalMs: applied });
2741
3857
  }
2742
3858
  return null;
2743
3859
  }
2744
3860
  if (req.method !== "GET")
2745
3861
  return null;
2746
3862
  if (!usageStore)
2747
- return json3({ error: "usage store not active" }, 503);
3863
+ return json12({ error: "usage store not active" }, 503);
2748
3864
  const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2749
3865
  const sinceTs = (days) => Date.now() - days * 86400000;
2750
3866
  if (p === "/api/usage/um-users") {
2751
- return json3({ users: usageStore.umUsers(device) });
3867
+ return json12({ users: usageStore.umUsers(device) });
2752
3868
  }
2753
3869
  if (p === "/api/usage/client") {
2754
3870
  const ip = url.searchParams.get("ip");
2755
3871
  if (!ip)
2756
- return json3({ error: "ip required" }, 400);
3872
+ return json12({ error: "ip required" }, 400);
2757
3873
  const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
2758
- return json3(withTotals(series));
3874
+ return json12(withTotals(series));
2759
3875
  }
2760
3876
  if (p === "/api/usage/um-user") {
2761
3877
  const user = url.searchParams.get("user");
2762
3878
  if (!user)
2763
- return json3({ error: "user required" }, 400);
3879
+ return json12({ error: "user required" }, 400);
2764
3880
  const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
2765
- return json3(withTotals(series));
3881
+ return json12(withTotals(series));
2766
3882
  }
2767
3883
  if (p === "/api/usage/heatmap") {
2768
3884
  const user = url.searchParams.get("user");
2769
3885
  const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
2770
3886
  const total = days.reduce((s, d) => s + d.count, 0);
2771
3887
  const max = days.reduce((m, d) => Math.max(m, d.count), 0);
2772
- return json3({ days, total, max });
3888
+ return json12({ days, total, max });
2773
3889
  }
2774
3890
  return null;
2775
3891
  }
@@ -2789,31 +3905,31 @@ function snapStore() {
2789
3905
  async function featureRoutes(req, url) {
2790
3906
  const p = url.pathname;
2791
3907
  if (p === "/api/snapshots" && req.method === "GET") {
2792
- const store = await snapStore();
3908
+ const store3 = await snapStore();
2793
3909
  const cfg = getConfig();
2794
- const all = Object.keys(cfg.devices).flatMap((d) => store.list(d, 200, false));
3910
+ const all = Object.keys(cfg.devices).flatMap((d) => store3.list(d, 200, false));
2795
3911
  all.sort((a, b) => b.ts - a.ts);
2796
- return json3({ snapshots: all });
3912
+ return json12({ snapshots: all });
2797
3913
  }
2798
3914
  const snapMatch = p.match(/^\/api\/snapshot\/(.+)$/);
2799
3915
  if (snapMatch && req.method === "GET") {
2800
- const store = await snapStore();
2801
- const s = store.get(decodeURIComponent(snapMatch[1]));
2802
- return s ? json3(s) : json3({ error: "not found" }, 404);
3916
+ const store3 = await snapStore();
3917
+ const s = store3.get(decodeURIComponent(snapMatch[1]));
3918
+ return s ? json12(s) : json12({ error: "not found" }, 404);
2803
3919
  }
2804
3920
  if (p === "/api/snapshots/diff" && req.method === "POST") {
2805
- const store = await snapStore();
3921
+ const store3 = await snapStore();
2806
3922
  const b = await readJson(req);
2807
- const from = b?.from ? store.get(b.from) : null;
2808
- const to = b?.to ? store.get(b.to) : null;
3923
+ const from = b?.from ? store3.get(b.from) : null;
3924
+ const to = b?.to ? store3.get(b.to) : null;
2809
3925
  if (!from || !to) {
2810
- return json3({ error: "both 'from' and 'to' snapshot ids are required" }, 400);
3926
+ return json12({ error: "both 'from' and 'to' snapshot ids are required" }, 400);
2811
3927
  }
2812
3928
  const diff = diffLines(normalizeExport(from.body), normalizeExport(to.body), {
2813
3929
  fromLabel: from.label ?? from.id,
2814
3930
  toLabel: to.label ?? to.id
2815
3931
  });
2816
- return json3({
3932
+ return json12({
2817
3933
  from: { id: from.id, label: from.label, ts: from.ts, device: from.device },
2818
3934
  to: { id: to.id, label: to.label, ts: to.ts, device: to.device },
2819
3935
  summary: diff.summary,
@@ -2827,16 +3943,16 @@ async function featureRoutes(req, url) {
2827
3943
  ...b?.script ? splitCommands(b.script) : []
2828
3944
  ];
2829
3945
  if (commands.length === 0)
2830
- return json3({ error: "no commands provided" }, 400);
3946
+ return json12({ error: "no commands provided" }, 400);
2831
3947
  const plan = buildChangePlan(commands);
2832
- return json3({ plan, text: renderPlan(plan) });
3948
+ return json12({ plan, text: renderPlan(plan) });
2833
3949
  }
2834
3950
  if (p === "/api/s3" && req.method === "GET") {
2835
- return json3({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
3951
+ return json12({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
2836
3952
  }
2837
3953
  if (p === "/api/s3/list" && req.method === "GET") {
2838
3954
  if (!isS3Configured())
2839
- return json3({ configured: false, objects: [] });
3955
+ return json12({ configured: false, objects: [] });
2840
3956
  const prefix = url.searchParams.get("prefix") ?? "";
2841
3957
  try {
2842
3958
  const res = await getS3Client().list({ prefix: prefix || undefined, maxKeys: 1000 });
@@ -2845,42 +3961,42 @@ async function featureRoutes(req, url) {
2845
3961
  size: o.size ?? 0,
2846
3962
  lastModified: o.lastModified ?? null
2847
3963
  }));
2848
- return json3({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
3964
+ return json12({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
2849
3965
  } catch (e) {
2850
- return json3({ error: clientError(e) }, 502);
3966
+ return json12({ error: clientError(e) }, 502);
2851
3967
  }
2852
3968
  }
2853
3969
  if (p === "/api/s3/presign" && req.method === "GET") {
2854
3970
  if (!isS3Configured())
2855
- return json3({ error: "S3 not configured" }, 400);
3971
+ return json12({ error: "S3 not configured" }, 400);
2856
3972
  const key = url.searchParams.get("key");
2857
3973
  if (!key)
2858
- return json3({ error: "key required" }, 400);
3974
+ return json12({ error: "key required" }, 400);
2859
3975
  try {
2860
3976
  const link = getS3Client().presign(key, { expiresIn: presignExpiresIn(), method: "GET" });
2861
- return json3({ url: link });
3977
+ return json12({ url: link });
2862
3978
  } catch (e) {
2863
- return json3({ error: clientError(e) }, 502);
3979
+ return json12({ error: clientError(e) }, 502);
2864
3980
  }
2865
3981
  }
2866
3982
  if (p === "/api/s3/delete" && req.method === "POST") {
2867
3983
  if (!isS3Configured())
2868
- return json3({ error: "S3 not configured" }, 400);
3984
+ return json12({ error: "S3 not configured" }, 400);
2869
3985
  const b = await readJson(req);
2870
3986
  if (!b?.key)
2871
- return json3({ error: "key required" }, 400);
3987
+ return json12({ error: "key required" }, 400);
2872
3988
  try {
2873
3989
  const client = getS3Client();
2874
3990
  if (!await client.exists(b.key))
2875
- return json3({ error: "not found" }, 404);
3991
+ return json12({ error: "not found" }, 404);
2876
3992
  await client.delete(b.key);
2877
- return json3({ ok: true, key: b.key });
3993
+ return json12({ ok: true, key: b.key });
2878
3994
  } catch (e) {
2879
- return json3({ error: clientError(e) }, 502);
3995
+ return json12({ error: clientError(e) }, 502);
2880
3996
  }
2881
3997
  }
2882
3998
  if (p === "/api/backups" && req.method === "GET") {
2883
- return json3({
3999
+ return json12({
2884
4000
  dir: backupDir(),
2885
4001
  devices: Object.keys(getConfig().devices),
2886
4002
  backups: listBackups()
@@ -2890,14 +4006,14 @@ async function featureRoutes(req, url) {
2890
4006
  const b = await readJson(req);
2891
4007
  const raw = b?.dir?.trim();
2892
4008
  if (!raw)
2893
- return json3({ error: "dir required" }, 400);
4009
+ return json12({ error: "dir required" }, 400);
2894
4010
  const dir = raw === "~" || raw.startsWith("~/") ? join4(homedir3(), raw.slice(1)) : raw;
2895
4011
  const next = { ...getConfig(), backupDir: dir };
2896
4012
  setConfig(next);
2897
4013
  try {
2898
4014
  atomicWrite(getConfigSource().path, serializeConfig(next));
2899
4015
  } catch (e) {
2900
- return json3({
4016
+ return json12({
2901
4017
  ok: true,
2902
4018
  dir: backupDir(),
2903
4019
  persisted: false,
@@ -2905,16 +4021,16 @@ async function featureRoutes(req, url) {
2905
4021
  });
2906
4022
  }
2907
4023
  recordVersion(getConfig(), "auto", Date.now(), "backup path changed");
2908
- return json3({ ok: true, dir: backupDir(), persisted: true });
4024
+ return json12({ ok: true, dir: backupDir(), persisted: true });
2909
4025
  }
2910
4026
  if (p === "/api/backups/get" && req.method === "GET") {
2911
4027
  const name = url.searchParams.get("name");
2912
4028
  if (!name)
2913
- return json3({ error: "name required" }, 400);
4029
+ return json12({ error: "name required" }, 400);
2914
4030
  try {
2915
- return json3({ name, content: readBackup(name) });
4031
+ return json12({ name, content: readBackup(name) });
2916
4032
  } catch {
2917
- return json3({ error: "not found" }, 404);
4033
+ return json12({ error: "not found" }, 404);
2918
4034
  }
2919
4035
  }
2920
4036
  if (p === "/api/backups/raw" && req.method === "GET") {
@@ -2935,41 +4051,41 @@ async function featureRoutes(req, url) {
2935
4051
  if (p === "/api/backups/upload" && req.method === "POST") {
2936
4052
  const b = await readJson(req);
2937
4053
  if (!b?.name || typeof b.content !== "string") {
2938
- return json3({ error: "name and content are required" }, 400);
4054
+ return json12({ error: "name and content are required" }, 400);
2939
4055
  }
2940
4056
  try {
2941
4057
  const safe = b.name.endsWith(".rsc") ? b.name : `${b.name}.rsc`;
2942
- return json3({ ok: true, name: writeBackup(safe, b.content) });
4058
+ return json12({ ok: true, name: writeBackup(safe, b.content) });
2943
4059
  } catch (e) {
2944
- return json3({ error: clientError(e) }, 400);
4060
+ return json12({ error: clientError(e) }, 400);
2945
4061
  }
2946
4062
  }
2947
4063
  if (p === "/api/backups/rename" && req.method === "POST") {
2948
4064
  const b = await readJson(req);
2949
4065
  if (!b?.name || !b?.new_name)
2950
- return json3({ error: "name and new_name are required" }, 400);
4066
+ return json12({ error: "name and new_name are required" }, 400);
2951
4067
  try {
2952
- return json3({ ok: true, name: renameBackup(b.name, b.new_name) });
4068
+ return json12({ ok: true, name: renameBackup(b.name, b.new_name) });
2953
4069
  } catch (e) {
2954
- return json3({ error: clientError(e) }, 400);
4070
+ return json12({ error: clientError(e) }, 400);
2955
4071
  }
2956
4072
  }
2957
4073
  if (p === "/api/backups/delete" && req.method === "POST") {
2958
4074
  const b = await readJson(req);
2959
4075
  if (!b?.name)
2960
- return json3({ error: "name required" }, 400);
4076
+ return json12({ error: "name required" }, 400);
2961
4077
  try {
2962
- return deleteBackup(b.name) ? json3({ ok: true }) : json3({ error: "not found" }, 404);
4078
+ return deleteBackup(b.name) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
2963
4079
  } catch (e) {
2964
- return json3({ error: clientError(e) }, 400);
4080
+ return json12({ error: clientError(e) }, 400);
2965
4081
  }
2966
4082
  }
2967
4083
  if (p === "/api/backups/restore" && req.method === "POST") {
2968
4084
  const b = await readJson(req);
2969
4085
  if (!b?.name)
2970
- return json3({ error: "name required" }, 400);
4086
+ return json12({ error: "name required" }, 400);
2971
4087
  const device = resolveDeviceName(b.device);
2972
- return json3(await restoreLocalBackup(device, b.name, b.confirm === true));
4088
+ return json12(await restoreLocalBackup(device, b.name, b.confirm === true));
2973
4089
  }
2974
4090
  if (p === "/api/backups/create" && req.method === "POST") {
2975
4091
  const b = await readJson(req);
@@ -2981,14 +4097,14 @@ async function featureRoutes(req, url) {
2981
4097
  compact: b?.compact === true,
2982
4098
  terse: b?.terse === true
2983
4099
  });
2984
- return r.ok ? json3(r) : json3({ error: r.error ?? "export failed" }, 502);
4100
+ return r.ok ? json12(r) : json12({ error: r.error ?? "export failed" }, 502);
2985
4101
  }
2986
4102
  return null;
2987
4103
  }
2988
4104
  async function runDashboard(cfg, transportLabel) {
2989
- const store = await openSqliteStore(cfg.dbPath);
4105
+ const store3 = await openSqliteStore(cfg.dbPath);
2990
4106
  configureRecorder({
2991
- store,
4107
+ store: store3,
2992
4108
  capture: {
2993
4109
  captureBody: cfg.captureBody,
2994
4110
  maxBodyBytes: cfg.maxBodyBytes,
@@ -3000,13 +4116,13 @@ async function runDashboard(cfg, transportLabel) {
3000
4116
  startHealthChecks(30000);
3001
4117
  startGeoLookups();
3002
4118
  try {
3003
- usageStore = await openUsageStore(join4(dirname5(cfg.dbPath), "usage.db"));
4119
+ usageStore = await openUsageStore(join4(dirname7(cfg.dbPath), "usage.db"));
3004
4120
  startUsageSampler(usageStore);
3005
4121
  } catch (e) {
3006
4122
  logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
3007
4123
  }
3008
4124
  try {
3009
- capsmanStore = await openCapsmanStore(join4(dirname5(cfg.dbPath), "capsman.db"));
4125
+ capsmanStore = await openCapsmanStore(join4(dirname7(cfg.dbPath), "capsman.db"));
3010
4126
  startCapsmanSampler(capsmanStore);
3011
4127
  } catch (e) {
3012
4128
  logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
@@ -3080,12 +4196,39 @@ async function runDashboard(cfg, transportLabel) {
3080
4196
  const driftResp = await driftRoutes(req, url);
3081
4197
  if (driftResp)
3082
4198
  return driftResp;
4199
+ const txnResp = await txnRoutes(req, url);
4200
+ if (txnResp)
4201
+ return txnResp;
4202
+ const flowResp = await flowRoutes(req, url);
4203
+ if (flowResp)
4204
+ return flowResp;
4205
+ const rolloutResp = await rolloutRoutes(req, url);
4206
+ if (rolloutResp)
4207
+ return rolloutResp;
4208
+ const policyResp = await policyRoutes(req, url);
4209
+ if (policyResp)
4210
+ return policyResp;
4211
+ const simResp = await simRoutes(req, url);
4212
+ if (simResp)
4213
+ return simResp;
4214
+ const scheduleResp = await scheduleRoutes(req, url);
4215
+ if (scheduleResp)
4216
+ return scheduleResp;
4217
+ const explainResp = await explainRoutes(req, url);
4218
+ if (explainResp)
4219
+ return explainResp;
4220
+ const attackResp = await attackRoutes(req, url);
4221
+ if (attackResp)
4222
+ return attackResp;
4223
+ const alertResp = await alertRoutes(req, url, getEventStore());
4224
+ if (alertResp)
4225
+ return alertResp;
3083
4226
  const memoryResp = await memoryRoutes(req, url);
3084
4227
  if (memoryResp)
3085
4228
  return memoryResp;
3086
4229
  const db = getEventStore();
3087
4230
  if (!db)
3088
- return json3({ error: "recorder not active" }, 503);
4231
+ return json12({ error: "recorder not active" }, 503);
3089
4232
  if (url.pathname === "/api/events" && req.method === "DELETE") {
3090
4233
  let body = {};
3091
4234
  try {
@@ -3093,7 +4236,7 @@ async function runDashboard(cfg, transportLabel) {
3093
4236
  } catch {}
3094
4237
  const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
3095
4238
  const removed = body.all === true ? db.clear() : db.delete(ids);
3096
- return json3({ removed, total: db.total() });
4239
+ return json12({ removed, total: db.total() });
3097
4240
  }
3098
4241
  if (url.pathname === "/api/ssh-pool") {
3099
4242
  const cfg2 = getConfig();
@@ -3102,7 +4245,7 @@ async function runDashboard(cfg, transportLabel) {
3102
4245
  const totalInflight = ps.reduce((s, p) => s + p.inflight, 0);
3103
4246
  const totalIdle = ps.filter((p) => p.idle).length;
3104
4247
  const totalBusy = ps.filter((p) => p.inflight > 0).length;
3105
- return json3({
4248
+ return json12({
3106
4249
  enabled,
3107
4250
  config: {
3108
4251
  keepAlive: cfg2.ssh.keepAlive,
@@ -3131,16 +4274,39 @@ async function runDashboard(cfg, transportLabel) {
3131
4274
  });
3132
4275
  }
3133
4276
  if (url.pathname === "/api/devices") {
3134
- return json3(devicesPayload(db));
4277
+ return json12(devicesPayload(db));
4278
+ }
4279
+ if (url.pathname === "/api/capabilities") {
4280
+ const devices = Object.keys(getConfig().devices).map((name) => ({
4281
+ device: name,
4282
+ capabilities: serializeCapabilities(peekCapabilities(name))
4283
+ }));
4284
+ return json12({ devices });
4285
+ }
4286
+ if (url.pathname === "/api/capabilities/refresh" && req.method === "POST") {
4287
+ const b = await readJson(req);
4288
+ if (typeof b?.device !== "string")
4289
+ return json12({ error: "device (string) is required" }, 400);
4290
+ if (!(b.device in getConfig().devices)) {
4291
+ return json12({ error: `unknown device: ${b.device}` }, 404);
4292
+ }
4293
+ invalidateCapabilities(b.device);
4294
+ try {
4295
+ const caps = await getCapabilities(b.device);
4296
+ return json12({ device: b.device, capabilities: serializeCapabilities(caps) });
4297
+ } catch (e) {
4298
+ logger.error(`Capability probe failed for '${b.device}': ${logError(e)}`);
4299
+ return json12({ error: clientError(e) }, 502);
4300
+ }
3135
4301
  }
3136
4302
  if (url.pathname === "/api/devices/toggle" && req.method === "POST") {
3137
4303
  const b = await readJson(req);
3138
4304
  if (typeof b?.device !== "string" || typeof b?.disabled !== "boolean") {
3139
- return json3({ error: "device (string) and disabled (boolean) are required" }, 400);
4305
+ return json12({ error: "device (string) and disabled (boolean) are required" }, 400);
3140
4306
  }
3141
4307
  const cfg2 = getConfig();
3142
4308
  if (!(b.device in cfg2.devices))
3143
- return json3({ error: `unknown device: ${b.device}` }, 404);
4309
+ return json12({ error: `unknown device: ${b.device}` }, 404);
3144
4310
  const dc = cfg2.devices[b.device];
3145
4311
  const next = {
3146
4312
  ...cfg2,
@@ -3161,7 +4327,7 @@ async function runDashboard(cfg, transportLabel) {
3161
4327
  if (persisted) {
3162
4328
  recordVersion(getConfig(), "auto", Date.now(), `device ${b.disabled ? "disabled" : "enabled"}: ${b.device}`);
3163
4329
  }
3164
- return json3({
4330
+ return json12({
3165
4331
  ok: true,
3166
4332
  persisted,
3167
4333
  requiresReconnect: true,
@@ -3174,17 +4340,17 @@ async function runDashboard(cfg, transportLabel) {
3174
4340
  const name = typeof b?.device === "string" ? b.device : "";
3175
4341
  const cfg2 = getConfig();
3176
4342
  if (!(name in cfg2.devices))
3177
- return json3({ error: `unknown device: ${name}` }, 404);
4343
+ return json12({ error: `unknown device: ${name}` }, 404);
3178
4344
  if (url.pathname === "/api/devices/reconnect")
3179
4345
  closeDevice(name);
3180
4346
  const status = await probeDevice(name, cfg2.devices[name]);
3181
- return json3({ ok: true, status, ...devicesPayload(db) });
4347
+ return json12({ ok: true, status, ...devicesPayload(db) });
3182
4348
  }
3183
4349
  if (url.pathname === "/api/topology") {
3184
- return json3(topologyPayload());
4350
+ return json12(topologyPayload());
3185
4351
  }
3186
4352
  if (url.pathname === "/api/config") {
3187
- return json3(configPayload());
4353
+ return json12(configPayload());
3188
4354
  }
3189
4355
  if (url.pathname === "/" || url.pathname === "/index.html") {
3190
4356
  return new Response(dashboardHtml(), {
@@ -3193,9 +4359,9 @@ async function runDashboard(cfg, transportLabel) {
3193
4359
  }
3194
4360
  if (url.pathname === "/api/releases/latest") {
3195
4361
  try {
3196
- return json3(await fetchLatestRelease());
4362
+ return json12(await fetchLatestRelease());
3197
4363
  } catch (e) {
3198
- return json3({ error: clientError(e, "fetch failed") }, 502);
4364
+ return json12({ error: clientError(e, "fetch failed") }, 502);
3199
4365
  }
3200
4366
  }
3201
4367
  if (url.pathname === "/api/catalog" && req.method === "GET") {
@@ -3214,7 +4380,7 @@ async function runDashboard(cfg, transportLabel) {
3214
4380
  }));
3215
4381
  const prompts = listPrompts();
3216
4382
  const groups = [...new Set(modules.map((m) => m.group))].sort();
3217
- return json3({
4383
+ return json12({
3218
4384
  modules,
3219
4385
  prompts,
3220
4386
  groups,
@@ -3229,25 +4395,25 @@ async function runDashboard(cfg, transportLabel) {
3229
4395
  }
3230
4396
  if (url.pathname === "/api/releases" && req.method === "GET") {
3231
4397
  try {
3232
- return json3(await fetchAllReleases());
4398
+ return json12(await fetchAllReleases());
3233
4399
  } catch (e) {
3234
- return json3({ error: clientError(e, "fetch failed") }, 502);
4400
+ return json12({ error: clientError(e, "fetch failed") }, 502);
3235
4401
  }
3236
4402
  }
3237
4403
  if (url.pathname === "/api/upgrade" && req.method === "POST") {
3238
4404
  const body = await readJson(req);
3239
4405
  const version = String(body?.version ?? "latest");
3240
4406
  if (version !== "latest" && !/^\d+\.\d+\.\d+$/.test(version)) {
3241
- return json3({ ok: false, error: `Invalid version "${version}" (use "latest" or x.y.z).` }, 400);
4407
+ return json12({ ok: false, error: `Invalid version "${version}" (use "latest" or x.y.z).` }, 400);
3242
4408
  }
3243
4409
  const spec = `@usex/mikrotik-mcp@${version}`;
3244
4410
  logger.warn(`Dashboard requested upgrade: bun i -g ${spec}`);
3245
4411
  const { ok, log } = await runUpgrade(spec);
3246
4412
  if (!ok)
3247
- return json3({ ok: false, version, log }, 500);
4413
+ return json12({ ok: false, version, log }, 500);
3248
4414
  const willRestart = body?.restart !== false;
3249
4415
  const relaunched = willRestart ? restartProcess() : false;
3250
- return json3({
4416
+ return json12({
3251
4417
  ok: true,
3252
4418
  version,
3253
4419
  log,
@@ -3257,7 +4423,7 @@ async function runDashboard(cfg, transportLabel) {
3257
4423
  }
3258
4424
  if (url.pathname === "/api/meta") {
3259
4425
  const f = facets(db);
3260
- return json3({
4426
+ return json12({
3261
4427
  ...f,
3262
4428
  version: VERSION,
3263
4429
  risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
@@ -3268,19 +4434,19 @@ async function runDashboard(cfg, transportLabel) {
3268
4434
  }
3269
4435
  if (url.pathname === "/api/events") {
3270
4436
  const filter = filterFromQuery(url);
3271
- return json3({ events: db.query(filter), total: db.total() });
4437
+ return json12({ events: db.query(filter), total: db.total() });
3272
4438
  }
3273
4439
  const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
3274
4440
  if (eventMatch) {
3275
4441
  const e = db.get(decodeURIComponent(eventMatch[1]));
3276
- return e ? json3(e) : json3({ error: "not found" }, 404);
4442
+ return e ? json12(e) : json12({ error: "not found" }, 404);
3277
4443
  }
3278
4444
  if (url.pathname === "/api/stats") {
3279
4445
  const now = Date.now();
3280
- const windowMs = Number(url.searchParams.get("window") ?? 3600000);
4446
+ const windowMs2 = Number(url.searchParams.get("window") ?? 3600000);
3281
4447
  const buckets = Number(url.searchParams.get("buckets") ?? 60);
3282
- const events = db.query({ since: now - windowMs, limit: 5000 });
3283
- return json3(computeStats(events, { now, windowMs, buckets }));
4448
+ const events = db.query({ since: now - windowMs2, limit: 5000 });
4449
+ return json12(computeStats(events, { now, windowMs: windowMs2, buckets }));
3284
4450
  }
3285
4451
  return new Response("Not Found", { status: 404 });
3286
4452
  }
@@ -3299,7 +4465,7 @@ async function runDashboard(cfg, transportLabel) {
3299
4465
  return await dashboardRoute(req, url, srv, configAdmin, transportLabel);
3300
4466
  } catch (e) {
3301
4467
  logger.error(`Dashboard request failed (${url.pathname}): ${logError(e)}`);
3302
- return json3({ error: clientError(e) }, 502);
4468
+ return json12({ error: clientError(e) }, 502);
3303
4469
  }
3304
4470
  },
3305
4471
  websocket: {
@@ -3311,11 +4477,19 @@ async function runDashboard(cfg, transportLabel) {
3311
4477
  } catch {}
3312
4478
  });
3313
4479
  } else {
3314
- ws.data.unsub = subscribe((e) => {
4480
+ const send = (payload2) => {
3315
4481
  try {
3316
- ws.send(JSON.stringify({ type: "event", event: e }));
4482
+ ws.send(JSON.stringify(payload2));
3317
4483
  } catch {}
3318
- });
4484
+ };
4485
+ const unsubEvents = subscribe((e) => send({ type: "event", event: e }));
4486
+ const unsubTxn = subscribeTxn((update) => send({ type: "txn", update }));
4487
+ const unsubRollout = subscribeRollout((update) => send({ type: "rollout", update }));
4488
+ ws.data.unsub = () => {
4489
+ unsubEvents();
4490
+ unsubTxn();
4491
+ unsubRollout();
4492
+ };
3319
4493
  }
3320
4494
  ws.send(JSON.stringify({ type: "hello", transport: transportLabel }));
3321
4495
  },
@@ -3336,14 +4510,14 @@ async function runDashboard(cfg, transportLabel) {
3336
4510
  }
3337
4511
  return {
3338
4512
  server,
3339
- store,
4513
+ store: store3,
3340
4514
  stop() {
3341
4515
  stopHealthChecks();
3342
4516
  stopGeoLookups();
3343
4517
  stopUsageSampler();
3344
4518
  stopCapsmanSampler();
3345
4519
  server.stop(true);
3346
- store.close();
4520
+ store3.close();
3347
4521
  usageStore?.close();
3348
4522
  usageStore = null;
3349
4523
  capsmanStore?.close();
@@ -3660,6 +4834,12 @@ async function runStdio() {
3660
4834
  const transport = new StdioServerTransport;
3661
4835
  await server.connect(transport);
3662
4836
  logger.info(`MCP server ready on stdio \u2014 ${toolCount} tools, ${promptCount} prompts, ${uiViewCount} app views${readOnly ? " [READ-ONLY]" : ""}`);
4837
+ setMcpAlertSender((n) => {
4838
+ server.server.sendLoggingMessage({
4839
+ level: n.severity === "critical" || n.severity === "high" ? "error" : "info",
4840
+ data: `[alert:${n.kind}] ${n.title}${n.device ? ` \xB7 ${n.device}` : ""} ` + `(severity ${n.severity}, rule ${n.ruleId})${n.body ? ` \u2014 ${n.body}` : ""}`
4841
+ }).catch(() => {});
4842
+ });
3663
4843
  if (!getConfig().disableUpdateCheck) {
3664
4844
  whisperUpdate(server).catch(() => {});
3665
4845
  }
@@ -3730,6 +4910,100 @@ async function printBanner() {
3730
4910
  }
3731
4911
  }
3732
4912
 
4913
+ // src/alerts/sampler.ts
4914
+ var DEFAULT_ALERT_SAMPLE_MS = 30000;
4915
+ var timer4 = null;
4916
+ var snapshotStorePromise = null;
4917
+ function getSnapshotStore() {
4918
+ snapshotStorePromise ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
4919
+ return snapshotStorePromise;
4920
+ }
4921
+ function computeMetricSample(windowMs2, now = Date.now()) {
4922
+ const store3 = getEventStore();
4923
+ if (!store3)
4924
+ return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
4925
+ const events = store3.query({ since: now - windowMs2, limit: 1e4 });
4926
+ const calls = events.length;
4927
+ if (calls === 0)
4928
+ return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
4929
+ const durations = events.map((e) => e.durationMs);
4930
+ return {
4931
+ calls,
4932
+ errors: events.reduce((n, e) => n + (e.isError ? 1 : 0), 0),
4933
+ avgDurationMs: durations.reduce((a, b) => a + b, 0) / calls,
4934
+ p95DurationMs: percentile(durations, 95)
4935
+ };
4936
+ }
4937
+ async function lastSeen(t) {
4938
+ switch (t.absence) {
4939
+ case "tool_call": {
4940
+ const store3 = getEventStore();
4941
+ if (!store3)
4942
+ return;
4943
+ const rows = store3.query({
4944
+ limit: 1,
4945
+ tool: t.tool?.length === 1 ? t.tool[0] : undefined,
4946
+ device: t.device?.length === 1 ? t.device[0] : undefined
4947
+ });
4948
+ return rows[0]?.ts;
4949
+ }
4950
+ case "snapshot": {
4951
+ const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
4952
+ let newest;
4953
+ try {
4954
+ const store3 = await getSnapshotStore();
4955
+ for (const d of devices) {
4956
+ const at = store3.latest(d)?.ts;
4957
+ if (at !== undefined && (newest === undefined || at > newest))
4958
+ newest = at;
4959
+ }
4960
+ } catch (e) {
4961
+ logger.debug(`[alerts] snapshot lookup failed: ${e instanceof Error ? e.message : e}`);
4962
+ return Date.now();
4963
+ }
4964
+ return newest;
4965
+ }
4966
+ case "device_seen": {
4967
+ const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
4968
+ let newest;
4969
+ for (const d of devices) {
4970
+ const st = getDeviceStatus(d);
4971
+ if (st.reachable !== true || st.checkedAt == null)
4972
+ continue;
4973
+ if (newest === undefined || st.checkedAt > newest)
4974
+ newest = st.checkedAt;
4975
+ }
4976
+ return newest;
4977
+ }
4978
+ }
4979
+ }
4980
+ async function sampleAlertsOnce(now = Date.now()) {
4981
+ const engine = getAlertEngine();
4982
+ if (!engine)
4983
+ return;
4984
+ if (engine.hasMetricRules()) {
4985
+ engine.sample((windowMs2) => computeMetricSample(windowMs2, now));
4986
+ }
4987
+ if (engine.hasAbsenceRules()) {
4988
+ await engine.sampleAbsence(lastSeen, now);
4989
+ }
4990
+ }
4991
+ function startAlertSampler(intervalMs = DEFAULT_ALERT_SAMPLE_MS) {
4992
+ stopAlertSampler();
4993
+ timer4 = setInterval(() => {
4994
+ sampleAlertsOnce().catch((e) => {
4995
+ logger.debug(`[alerts] sampler tick failed: ${e instanceof Error ? e.message : String(e)}`);
4996
+ });
4997
+ }, intervalMs);
4998
+ timer4.unref?.();
4999
+ logger.debug(`[alerts] metric sampler started (${intervalMs}ms)`);
5000
+ }
5001
+ function stopAlertSampler() {
5002
+ if (timer4)
5003
+ clearInterval(timer4);
5004
+ timer4 = null;
5005
+ }
5006
+
3733
5007
  // src/cli.ts
3734
5008
  var HELP = `${SERVER_NAME} v${VERSION} \u2014 MikroTik RouterOS MCP server
3735
5009
 
@@ -3882,6 +5156,29 @@ async function main() {
3882
5156
  warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
3883
5157
  const deviceNames = Object.keys(cfg.devices);
3884
5158
  logger.info(`Starting ${SERVER_NAME} v${VERSION} (transport=${cfg.mcp.transport}, ` + `devices=${deviceNames.length === 1 ? deviceNames[0] : deviceNames.join("/")}, ` + `ssh-pool=${cfg.ssh.keepAlive ? "on" : "off"})`);
5159
+ if (cfg.alerts?.enabled !== false && cfg.alerts) {
5160
+ try {
5161
+ const rules = parseRules(cfg.alerts.rules);
5162
+ setAlertEngine(new AlertEngine({
5163
+ rules,
5164
+ channels: cfg.alerts.channels,
5165
+ historyDb: cfg.dashboard.dbPath,
5166
+ maxHistory: cfg.alerts.maxHistory
5167
+ }));
5168
+ startAlertSampler();
5169
+ logger.info(`Alerting enabled: ${rules.length} rule(s)`);
5170
+ } catch (e) {
5171
+ logger.error(`Alerting disabled \u2014 invalid rules: ${e instanceof Error ? e.message : String(e)}`);
5172
+ }
5173
+ }
5174
+ startScheduler().catch((e) => {
5175
+ logger.error(`Scheduled audits disabled: ${e instanceof Error ? e.message : String(e)}`);
5176
+ });
5177
+ try {
5178
+ startAttackDetection();
5179
+ } catch (e) {
5180
+ logger.error(`Attack detection disabled: ${e instanceof Error ? e.message : String(e)}`);
5181
+ }
3885
5182
  let shuttingDown = false;
3886
5183
  const shutdown = (signal) => {
3887
5184
  if (shuttingDown)
@@ -3890,6 +5187,9 @@ async function main() {
3890
5187
  logger.info(`Received ${signal}, shutting down\u2026`);
3891
5188
  closeAll();
3892
5189
  closeMemoryStore();
5190
+ stopAlertSampler();
5191
+ stopScheduler();
5192
+ stopAttackDetection();
3893
5193
  process.exit(0);
3894
5194
  };
3895
5195
  process.on("SIGINT", () => shutdown("SIGINT"));