@usex/mikrotik-mcp 4.24.0 → 5.0.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 +1839 -554
  3. package/dist/index.d.ts +57 -0
  4. package/dist/index.js +1 -1
  5. package/dist/shared/{library-2f8ty72a.js → cli-2dj736ma.js} +34321 -22476
  6. package/dist/shared/{cli-ps0h2mxx.js → cli-vvy7yfyg.js} +1 -1
  7. package/dist/shared/{cli-10wtq3xb.js → library-13dhfdnq.js} +29451 -18574
  8. package/dist/shared/{library-phwm9kqe.js → library-syt5fmtj.js} +1 -1
  9. package/dist/ui/observability.html +70 -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 +3044 -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 +21 -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-2dj736ma.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,1261 @@ 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
+ logger.info("attack sweep requested from the dashboard");
2707
+ const result = await sweep({ respond: false });
2708
+ return json9({
2709
+ incidents: result.incidents,
2710
+ unavailable: result.unavailable,
2711
+ devices: result.devices
2712
+ });
2713
+ }
2714
+ const rest = p.slice("/api/attacks/".length).split("/").filter(Boolean);
2715
+ if (rest[0] === "responses" && rest[1] && req.method === "DELETE") {
2716
+ const address = decodeURIComponent(rest[1]);
2717
+ const response = store3.responseFor(address);
2718
+ const devices = response?.devices ?? [];
2719
+ if (devices.length === 0)
2720
+ return json9({ error: `no recorded block for ${address}` }, 404);
2721
+ const results = await revokeBlock(address, devices, response?.list);
2722
+ store3.revokeResponse(address, now);
2723
+ return json9({ address, results });
2724
+ }
2725
+ if (rest.length === 1 && req.method === "GET") {
2726
+ const incident = store3.getIncident(decodeURIComponent(rest[0]));
2727
+ if (!incident)
2728
+ return json9({ error: "not found" }, 404);
2729
+ return json9({ incident, response: store3.responseFor(incident.source) });
2730
+ }
2731
+ if (rest.length === 2 && rest[1] === "respond" && req.method === "POST") {
2732
+ const incident = store3.getIncident(decodeURIComponent(rest[0]));
2733
+ if (!incident)
2734
+ return json9({ error: "not found" }, 404);
2735
+ const body = await req.json().catch(() => ({}));
2736
+ if (body.action === "dismiss") {
2737
+ return json9({ dismissed: incident.id });
2738
+ }
2739
+ const decision = decide({
2740
+ incident,
2741
+ policy: policyFromConfig(),
2742
+ guards: buildGuards(),
2743
+ recentBlockCount: store3.countRecentBlocks(incident.devices, now - 3600000),
2744
+ manual: true,
2745
+ confirm: body.confirm ?? false,
2746
+ timeout: body.timeout
2747
+ });
2748
+ if (!isPlan(decision)) {
2749
+ return json9({ refused: true, guard: decision.guard, reason: decision.reason }, 200);
2750
+ }
2751
+ if (decision.action === "escalate") {
2752
+ return json9({ escalated: true, reason: decision.reason });
2753
+ }
2754
+ if (!body.confirm) {
2755
+ return json9({ dryRun: true, plan: decision });
2756
+ }
2757
+ const applied = await executePlan(decision);
2758
+ const ok = applied.some((r) => r.ok);
2759
+ store3.recordResponse({
2760
+ incidentId: incident.id,
2761
+ action: decision.action,
2762
+ source: decision.source,
2763
+ devices: decision.devices,
2764
+ timeout: decision.timeout,
2765
+ list: decision.list,
2766
+ reason: decision.reason,
2767
+ ts: now,
2768
+ ok,
2769
+ error: ok ? undefined : applied.map((r) => r.detail).join("; ")
2770
+ });
2771
+ return json9({ applied, ok, plan: decision });
2772
+ }
2773
+ return json9({ error: "not found" }, 404);
2774
+ }
2775
+
2776
+ // src/observability/alert-routes.ts
2777
+ var JSON_HEADERS10 = { "content-type": "application/json; charset=utf-8" };
2778
+ function json10(body, status = 200) {
2779
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS10 });
2780
+ }
2781
+ function ruleRow(rule, subject, status, since) {
2782
+ return {
2783
+ id: rule.id,
2784
+ subject,
2785
+ description: rule.description,
2786
+ when: rule.when,
2787
+ severity: rule.severity,
2788
+ channels: rule.channels,
2789
+ for: rule.for,
2790
+ cooldown: rule.cooldown,
2791
+ enabled: rule.enabled,
2792
+ mutedUntil: rule.mutedUntil,
2793
+ status,
2794
+ since,
2795
+ warnings: ruleWarnings(rule)
2796
+ };
2797
+ }
2798
+ function previewRule(rule, events) {
2799
+ if (!isEventTrigger(rule.when))
2800
+ return { fires: 0, resolves: 0, matched: 0 };
2801
+ let state = initialState(events[0]?.ts ?? 0);
2802
+ let fires = 0;
2803
+ let resolves = 0;
2804
+ let matched = 0;
2805
+ for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
2806
+ const ev = {
2807
+ kind: "tool_call",
2808
+ tool: e.tool,
2809
+ device: e.device,
2810
+ risk: e.risk,
2811
+ isError: e.isError
2812
+ };
2813
+ const met = eventMet(rule.when, ev);
2814
+ if (met)
2815
+ matched++;
2816
+ const res = step(rule, state, met, e.ts);
2817
+ state = res.state;
2818
+ if (res.action.type === "fire")
2819
+ fires++;
2820
+ if (res.action.type === "resolve")
2821
+ resolves++;
2822
+ }
2823
+ return { fires, resolves, matched };
2824
+ }
2825
+ async function alertRoutes(req, url, db) {
2826
+ const p = url.pathname;
2827
+ if (!p.startsWith("/api/alerts"))
2828
+ return null;
2829
+ const engine = getAlertEngine();
2830
+ const channels = getConfig().alerts?.channels;
2831
+ if (p === "/api/alerts" && req.method === "GET") {
2832
+ if (!engine) {
2833
+ return json10({ configured: false, rules: [], active: [], channels: {} });
2834
+ }
2835
+ const now = Date.now();
2836
+ const rows = engine.snapshot().map(({ rule, subject, state }) => ruleRow(rule, subject, isMuted(rule, now) ? "muted" : !rule.enabled ? "disabled" : state.status, state.since));
2837
+ return json10({
2838
+ configured: true,
2839
+ rules: rows,
2840
+ active: rows.filter((r) => r.status === "firing"),
2841
+ channels: redactChannels(channels)
2842
+ });
2843
+ }
2844
+ if (p === "/api/alerts/history" && req.method === "GET") {
2845
+ if (!engine)
2846
+ return json10({ history: [] });
2847
+ const hours = Number(url.searchParams.get("hours") ?? 24);
2848
+ const history = await engine.history({
2849
+ sinceMs: Date.now() - hours * 3600000,
2850
+ ruleId: url.searchParams.get("rule") ?? undefined,
2851
+ limit: Number(url.searchParams.get("limit") ?? 200)
2852
+ });
2853
+ return json10({ history });
2854
+ }
2855
+ if (p === "/api/alerts/rules" && req.method === "POST") {
2856
+ if (!engine)
2857
+ return json10({ error: "alerting is not configured" }, 400);
2858
+ const parsed = AlertRuleSchema.safeParse(await req.json());
2859
+ if (!parsed.success) {
2860
+ return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
2861
+ }
2862
+ const rules = engine.configuredRules();
2863
+ if (rules.some((r) => r.id === parsed.data.id)) {
2864
+ return json10({ error: `rule '${parsed.data.id}' already exists` }, 409);
2865
+ }
2866
+ engine.setRules([...rules, parsed.data]);
2867
+ return json10({ ok: true, id: parsed.data.id });
2868
+ }
2869
+ const idMatch = p.match(/^\/api\/alerts\/rules\/([^/]+)$/);
2870
+ if (idMatch) {
2871
+ if (!engine)
2872
+ return json10({ error: "alerting is not configured" }, 400);
2873
+ const id = decodeURIComponent(idMatch[1]);
2874
+ const rules = engine.configuredRules();
2875
+ const existing = rules.find((r) => r.id === id);
2876
+ if (!existing)
2877
+ return json10({ error: `unknown rule: ${id}` }, 404);
2878
+ if (req.method === "DELETE") {
2879
+ engine.setRules(rules.filter((r) => r.id !== id));
2880
+ return json10({ ok: true });
2881
+ }
2882
+ if (req.method === "PATCH") {
2883
+ const body = await req.json();
2884
+ let mutedUntil = existing.mutedUntil;
2885
+ if (typeof body.mute === "string") {
2886
+ const ms = parseDuration(body.mute);
2887
+ if (ms === null)
2888
+ return json10({ error: `invalid duration: ${body.mute}` }, 400);
2889
+ mutedUntil = ms === 0 ? undefined : Date.now() + ms;
2890
+ delete body.mute;
2891
+ }
2892
+ const merged = AlertRuleSchema.safeParse({ ...existing, ...body, mutedUntil });
2893
+ if (!merged.success) {
2894
+ return json10({ error: merged.error.issues.map((i) => i.message).join("; ") }, 400);
2895
+ }
2896
+ engine.setRules(rules.map((r) => r.id === id ? merged.data : r));
2897
+ return json10({ ok: true, rule: merged.data });
2898
+ }
2899
+ }
2900
+ if (p === "/api/alerts/test" && req.method === "POST") {
2901
+ const body = await req.json();
2902
+ if (!channels || !body?.channel)
2903
+ return json10({ error: "channel is required" }, 400);
2904
+ try {
2905
+ const result = await deliver(body.channel, channels, {
2906
+ ruleId: "test",
2907
+ severity: "low",
2908
+ kind: "fire",
2909
+ title: "Test notification from mikrotik-mcp",
2910
+ body: "If you can read this, this channel is configured correctly.",
2911
+ at: Date.now()
2912
+ });
2913
+ return json10(result);
2914
+ } catch (e) {
2915
+ logger.error(`Alert channel test failed: ${logError(e)}`);
2916
+ return json10({ error: clientError(e) }, 502);
2917
+ }
2918
+ }
2919
+ if (p === "/api/alerts/preview" && req.method === "POST") {
2920
+ const parsed = AlertRuleSchema.safeParse(await req.json());
2921
+ if (!parsed.success) {
2922
+ return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
2923
+ }
2924
+ const hours = Number(url.searchParams.get("hours") ?? 24);
2925
+ if (!db) {
2926
+ return json10({ hours, sampled: 0, fires: 0, resolves: 0, matched: 0, noHistory: true });
2927
+ }
2928
+ const events = db.query({ since: Date.now() - hours * 3600000, limit: 5000 }).map((e) => ({
2929
+ ts: e.ts,
2930
+ tool: e.tool,
2931
+ device: e.device,
2932
+ risk: e.risk,
2933
+ isError: e.isError
2934
+ }));
2935
+ return json10({ hours, sampled: events.length, ...previewRule(parsed.data, events) });
2936
+ }
2937
+ return null;
2938
+ }
2939
+
2940
+ // src/observability/memory-routes.ts
2941
+ var JSON_HEADERS11 = { "content-type": "application/json; charset=utf-8" };
2942
+ function json11(body, status = 200) {
2943
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS11 });
2944
+ }
2945
+ async function bodyJson4(req) {
1846
2946
  return await req.json();
1847
2947
  }
1848
2948
  async function memoryRoutes(req, url) {
@@ -1851,29 +2951,29 @@ async function memoryRoutes(req, url) {
1851
2951
  return null;
1852
2952
  const cfg = getConfig();
1853
2953
  if (!cfg.memory.enabled) {
1854
- return json2({ error: "Knowledge-graph memory is disabled" }, 503);
2954
+ return json11({ error: "Knowledge-graph memory is disabled" }, 503);
1855
2955
  }
1856
2956
  if (p === "/api/memory/config" && req.method === "GET") {
1857
2957
  let stats = null;
1858
2958
  try {
1859
- const store2 = await getMemoryStore();
1860
- stats = store2.stats();
2959
+ const store4 = await getMemoryStore();
2960
+ stats = store4.stats();
1861
2961
  } catch {}
1862
- return json2({
2962
+ return json11({
1863
2963
  enabled: cfg.memory.enabled,
1864
2964
  dbPath: cfg.memory.dbPath,
1865
2965
  stats
1866
2966
  });
1867
2967
  }
1868
2968
  if (p === "/api/memory/config" && req.method === "POST") {
1869
- const body = await bodyJson2(req);
2969
+ const body = await bodyJson4(req);
1870
2970
  const updates = {};
1871
2971
  if (body.dbPath !== undefined && typeof body.dbPath === "string" && body.dbPath !== cfg.memory.dbPath) {
1872
2972
  updates.dbPath = body.dbPath;
1873
2973
  try {
1874
2974
  await reopenMemoryStore(body.dbPath);
1875
2975
  } catch (e) {
1876
- return json2({
2976
+ return json11({
1877
2977
  error: `Failed to open memory DB at ${body.dbPath}: ${e instanceof Error ? e.message : String(e)}`
1878
2978
  }, 400);
1879
2979
  }
@@ -1892,79 +2992,79 @@ async function memoryRoutes(req, url) {
1892
2992
  }
1893
2993
  let stats = null;
1894
2994
  try {
1895
- const store2 = await getMemoryStore();
1896
- stats = store2.stats();
2995
+ const store4 = await getMemoryStore();
2996
+ stats = store4.stats();
1897
2997
  } catch {}
1898
- return json2({
2998
+ return json11({
1899
2999
  ok: true,
1900
3000
  enabled: getConfig().memory.enabled,
1901
3001
  dbPath: getConfig().memory.dbPath,
1902
3002
  stats
1903
3003
  });
1904
3004
  }
1905
- const store = await getMemoryStore();
3005
+ const store3 = await getMemoryStore();
1906
3006
  if (p === "/api/memory/graph" && req.method === "GET") {
1907
- return json2(store.readGraph());
3007
+ return json11(store3.readGraph());
1908
3008
  }
1909
3009
  if (p === "/api/memory/stats" && req.method === "GET") {
1910
- return json2(store.stats());
3010
+ return json11(store3.stats());
1911
3011
  }
1912
3012
  if (p === "/api/memory/search" && req.method === "GET") {
1913
3013
  const q = url.searchParams.get("q") || "";
1914
3014
  const limit = Number(url.searchParams.get("limit") ?? 50);
1915
- return json2(store.searchNodes(q, limit));
3015
+ return json11(store3.searchNodes(q, limit));
1916
3016
  }
1917
3017
  if (p === "/api/memory/activity" && req.method === "GET") {
1918
3018
  const limit = Number(url.searchParams.get("limit") ?? 50);
1919
3019
  const since = url.searchParams.get("since");
1920
- return json2(store.activity(limit, since ? Number(since) : undefined));
3020
+ return json11(store3.activity(limit, since ? Number(since) : undefined));
1921
3021
  }
1922
3022
  const entityMatch = p.match(/^\/api\/memory\/entity\/(.+)$/);
1923
3023
  if (entityMatch && req.method === "GET") {
1924
3024
  const name = decodeURIComponent(entityMatch[1]);
1925
- const graph = store.openNodes([name]);
3025
+ const graph = store3.openNodes([name]);
1926
3026
  if (graph.entities.length === 0)
1927
- return json2({ error: "Entity not found" }, 404);
1928
- return json2({ entity: graph.entities[0], relations: graph.relations });
3027
+ return json11({ error: "Entity not found" }, 404);
3028
+ return json11({ entity: graph.entities[0], relations: graph.relations });
1929
3029
  }
1930
3030
  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 });
3031
+ const body = await bodyJson4(req);
3032
+ const created = store3.createEntities(body.entities ?? []);
3033
+ return json11({ created, count: created.length });
1934
3034
  }
1935
3035
  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 });
3036
+ const body = await bodyJson4(req);
3037
+ const created = store3.createRelations(body.relations ?? []);
3038
+ return json11({ created, count: created.length });
1939
3039
  }
1940
3040
  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 });
3041
+ const body = await bodyJson4(req);
3042
+ const results = store3.addObservations(body.observations ?? []);
3043
+ return json11({ results });
1944
3044
  }
1945
3045
  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 });
3046
+ const body = await bodyJson4(req);
3047
+ const removed = store3.deleteEntities(body.names ?? []);
3048
+ return json11({ removed });
1949
3049
  }
1950
3050
  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 });
3051
+ const body = await bodyJson4(req);
3052
+ const removed = store3.deleteRelations(body.relations ?? []);
3053
+ return json11({ removed });
1954
3054
  }
1955
3055
  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 });
3056
+ const body = await bodyJson4(req);
3057
+ const removed = store3.deleteObservations(body.deletions ?? []);
3058
+ return json11({ removed });
1959
3059
  }
1960
3060
  return null;
1961
3061
  }
1962
3062
 
1963
3063
  // src/observability/dashboard.ts
1964
3064
  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 });
3065
+ var JSON_HEADERS12 = { "content-type": "application/json; charset=utf-8" };
3066
+ function json12(body, status = 200) {
3067
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS12 });
1968
3068
  }
1969
3069
  function restartProcess() {
1970
3070
  let ok = false;
@@ -1992,7 +3092,7 @@ function runUpgrade(spec) {
1992
3092
  env: process.env,
1993
3093
  stdio: ["ignore", "pipe", "pipe"]
1994
3094
  });
1995
- const timer5 = setTimeout(() => {
3095
+ const timer4 = setTimeout(() => {
1996
3096
  child.kill();
1997
3097
  resolve({ ok: false, log: `${out}
1998
3098
  (timed out after 180s)` });
@@ -2000,12 +3100,12 @@ function runUpgrade(spec) {
2000
3100
  child.stdout?.on("data", (d) => out += d.toString());
2001
3101
  child.stderr?.on("data", (d) => out += d.toString());
2002
3102
  child.on("error", (e) => {
2003
- clearTimeout(timer5);
3103
+ clearTimeout(timer4);
2004
3104
  resolve({ ok: false, log: `${out}
2005
3105
  spawn error: ${String(e)}` });
2006
3106
  });
2007
3107
  child.on("exit", (code) => {
2008
- clearTimeout(timer5);
3108
+ clearTimeout(timer4);
2009
3109
  resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
2010
3110
  });
2011
3111
  });
@@ -2039,8 +3139,8 @@ function filterFromQuery(url) {
2039
3139
  until: num("until")
2040
3140
  };
2041
3141
  }
2042
- function facets(store) {
2043
- const recent = store.query({ limit: 5000 });
3142
+ function facets(store3) {
3143
+ const recent = store3.query({ limit: 5000 });
2044
3144
  const tools = new Set;
2045
3145
  const devices = new Set;
2046
3146
  for (const e of recent) {
@@ -2053,8 +3153,8 @@ function facets(store) {
2053
3153
  devices: [...devices].sort()
2054
3154
  };
2055
3155
  }
2056
- function deviceActivity(store) {
2057
- const recent = store.query({ limit: 5000 });
3156
+ function deviceActivity(store3) {
3157
+ const recent = store3.query({ limit: 5000 });
2058
3158
  const map = new Map;
2059
3159
  for (const e of recent) {
2060
3160
  if (!e.device)
@@ -2083,9 +3183,9 @@ function deviceActivity(store) {
2083
3183
  }
2084
3184
  return out;
2085
3185
  }
2086
- function devicesPayload(store) {
3186
+ function devicesPayload(store3) {
2087
3187
  const cfg = getConfig();
2088
- const activity = deviceActivity(store);
3188
+ const activity = deviceActivity(store3);
2089
3189
  const poolEnabled = isPoolEnabled();
2090
3190
  const poolMap = new Map(poolStatus().map((p) => [p.device, p]));
2091
3191
  const devices = Object.entries(cfg.devices).map(([name, dc]) => {
@@ -2096,7 +3196,8 @@ function devicesPayload(store) {
2096
3196
  host: dc.host,
2097
3197
  port: dc.port,
2098
3198
  mac: dc.mac,
2099
- transport: dc.mac ? "mac-telnet" : "ssh",
3199
+ transport: dc.mac ? "mac-telnet" : dc.api ? "rest" : "ssh",
3200
+ restPort: dc.api ? dc.apiPort ?? 443 : undefined,
2100
3201
  address: dc.mac ? dc.mac : `${dc.host}:${dc.port}`,
2101
3202
  username: dc.username,
2102
3203
  authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
@@ -2258,11 +3359,11 @@ function issues(error) {
2258
3359
  async function configRoutes(req, url, admin) {
2259
3360
  const p = url.pathname;
2260
3361
  if (p === "/api/config-schema" && req.method === "GET") {
2261
- return json3(configSchemaJson());
3362
+ return json12(configSchemaJson());
2262
3363
  }
2263
3364
  if (p === "/api/config/validate" && req.method === "POST") {
2264
3365
  const merged = mergeSecrets(await readJson(req), getConfig());
2265
- return json3(validateConfig(merged));
3366
+ return json12(validateConfig(merged));
2266
3367
  }
2267
3368
  if (p === "/api/config/test-device" && req.method === "POST") {
2268
3369
  const body = await readJson(req);
@@ -2271,22 +3372,22 @@ async function configRoutes(req, url, admin) {
2271
3372
  const merged = mergeSecrets(body?.config, currentDc);
2272
3373
  const parsed = DeviceConfigSchema.safeParse(merged);
2273
3374
  if (!parsed.success)
2274
- return json3({ ok: false, errors: issues(parsed.error) }, 400);
3375
+ return json12({ ok: false, errors: issues(parsed.error) }, 400);
2275
3376
  const status = await probeDevice(`config-test:${name}`, parsed.data);
2276
- return json3({ ok: true, status });
3377
+ return json12({ ok: true, status });
2277
3378
  }
2278
3379
  if (p === "/api/config/preview" && req.method === "POST") {
2279
3380
  const before = JSON.stringify(redact(getConfig()), null, 2);
2280
3381
  const after = JSON.stringify(await readJson(req) ?? {}, null, 2);
2281
3382
  const d = diffLines(before, after, { fromLabel: "current", toLabel: "edited" });
2282
- return json3({ summary: d.summary, unified: d.unified });
3383
+ return json12({ summary: d.summary, unified: d.unified });
2283
3384
  }
2284
3385
  if (p === "/api/config" && req.method === "POST") {
2285
3386
  const body = await readJson(req);
2286
3387
  const merged = mergeSecrets(body?.config, getConfig());
2287
3388
  const v = validateConfig(merged);
2288
3389
  if (!v.ok || !v.value)
2289
- return json3({ ok: false, errors: v.errors }, 400);
3390
+ return json12({ ok: false, errors: v.errors }, 400);
2290
3391
  const prev = getConfig();
2291
3392
  const before = JSON.stringify(redact(prev), null, 2);
2292
3393
  const after = JSON.stringify(redact(v.value), null, 2);
@@ -2295,25 +3396,25 @@ async function configRoutes(req, url, admin) {
2295
3396
  const nextDevs = Object.keys(v.value.devices);
2296
3397
  const devicesChanged = prevDevs.length !== nextDevs.length || prevDevs.some((d) => !nextDevs.includes(d));
2297
3398
  const res = admin.applyConfig(v.value, clampRollback(body?.rollbackMs));
2298
- return json3({ ok: true, ...res, devicesChanged, summary: diff.summary, unified: diff.unified });
3399
+ return json12({ ok: true, ...res, devicesChanged, summary: diff.summary, unified: diff.unified });
2299
3400
  }
2300
3401
  if (p === "/api/config/keep" && req.method === "POST") {
2301
3402
  const body = await readJson(req);
2302
3403
  const kept = admin.keepConfig(String(body?.pendingId ?? ""));
2303
3404
  if (kept)
2304
3405
  recordVersion(getConfig(), "auto", Date.now());
2305
- return json3({ kept });
3406
+ return json12({ kept });
2306
3407
  }
2307
3408
  if (p === "/api/config/rollback" && req.method === "POST") {
2308
3409
  const body = await readJson(req);
2309
3410
  const rolledBack = admin.rollback(String(body?.pendingId ?? ""));
2310
- return json3({ rolledBack, config: redact(getConfig()) });
3411
+ return json12({ rolledBack, config: redact(getConfig()) });
2311
3412
  }
2312
3413
  if (p === "/api/reload" && req.method === "POST") {
2313
3414
  const body = await readJson(req);
2314
3415
  if (body?.hard === true) {
2315
3416
  const relaunched = restartProcess();
2316
- return json3({
3417
+ return json12({
2317
3418
  ok: true,
2318
3419
  mode: "restart",
2319
3420
  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 +3425,10 @@ async function configRoutes(req, url, admin) {
2324
3425
  setConfig(cfg);
2325
3426
  const devices = Object.keys(cfg.devices);
2326
3427
  logger.info(`Config reloaded from source \u2014 ${devices.length} device(s): ${devices.join(", ")}`);
2327
- return json3({ ok: true, mode: "config", count: devices.length, devices });
3428
+ return json12({ ok: true, mode: "config", count: devices.length, devices });
2328
3429
  } catch (e) {
2329
3430
  logger.error(`Config reload failed: ${logError(e)}`);
2330
- return json3({ ok: false, error: clientError(e) }, 500);
3431
+ return json12({ ok: false, error: clientError(e) }, 500);
2331
3432
  }
2332
3433
  }
2333
3434
  if (p === "/api/config/history" && req.method === "GET") {
@@ -2343,55 +3444,55 @@ async function configRoutes(req, url, admin) {
2343
3444
  } catch {}
2344
3445
  return { ...v, drift: { added, removed } };
2345
3446
  });
2346
- return json3({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
3447
+ return json12({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
2347
3448
  }
2348
3449
  if (p === "/api/config/history/get" && req.method === "GET") {
2349
3450
  const id = url.searchParams.get("id");
2350
3451
  if (!id)
2351
- return json3({ error: "id required" }, 400);
3452
+ return json12({ error: "id required" }, 400);
2352
3453
  try {
2353
3454
  const v = readVersion(id);
2354
- return json3({
3455
+ return json12({
2355
3456
  ts: v.ts,
2356
3457
  kind: v.kind,
2357
3458
  label: v.label,
2358
3459
  config: JSON.stringify(redact(v.config), null, 2)
2359
3460
  });
2360
3461
  } catch {
2361
- return json3({ error: "not found" }, 404);
3462
+ return json12({ error: "not found" }, 404);
2362
3463
  }
2363
3464
  }
2364
3465
  if (p === "/api/config/history/diff" && req.method === "GET") {
2365
3466
  const id = url.searchParams.get("id");
2366
3467
  if (!id)
2367
- return json3({ error: "id required" }, 400);
3468
+ return json12({ error: "id required" }, 400);
2368
3469
  try {
2369
3470
  const before = JSON.stringify(redact(readVersion(id).config), null, 2);
2370
3471
  const after = JSON.stringify(redact(getConfig()), null, 2);
2371
3472
  const d = diffLines(before, after, { fromLabel: "this version", toLabel: "current" });
2372
- return json3({ summary: d.summary, unified: d.unified });
3473
+ return json12({ summary: d.summary, unified: d.unified });
2373
3474
  } catch {
2374
- return json3({ error: "not found" }, 404);
3475
+ return json12({ error: "not found" }, 404);
2375
3476
  }
2376
3477
  }
2377
3478
  if (p === "/api/config/history/checkpoint" && req.method === "POST") {
2378
3479
  const b = await readJson(req);
2379
3480
  const label = b?.label?.trim() || "checkpoint";
2380
- return json3({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
3481
+ return json12({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
2381
3482
  }
2382
3483
  if (p === "/api/config/history/restore" && req.method === "POST") {
2383
3484
  const b = await readJson(req);
2384
3485
  if (!b?.id)
2385
- return json3({ error: "id required" }, 400);
3486
+ return json12({ error: "id required" }, 400);
2386
3487
  let target;
2387
3488
  try {
2388
3489
  target = readVersion(b.id);
2389
3490
  } catch {
2390
- return json3({ error: "not found" }, 404);
3491
+ return json12({ error: "not found" }, 404);
2391
3492
  }
2392
3493
  const v = validateConfig(target.config);
2393
3494
  if (!v.ok || !v.value)
2394
- return json3({ ok: false, errors: v.errors }, 400);
3495
+ return json12({ ok: false, errors: v.errors }, 400);
2395
3496
  recordVersion(getConfig(), "auto", Date.now(), "before restore");
2396
3497
  setConfig(v.value);
2397
3498
  let persisted = true;
@@ -2402,13 +3503,13 @@ async function configRoutes(req, url, admin) {
2402
3503
  }
2403
3504
  const label = target.label ? `restored "${target.label}"` : `restored ${b.id}`;
2404
3505
  recordVersion(getConfig(), "auto", Date.now(), label);
2405
- return json3({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
3506
+ return json12({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
2406
3507
  }
2407
3508
  if (p === "/api/config/history/delete" && req.method === "POST") {
2408
3509
  const b = await readJson(req);
2409
3510
  if (!b?.id)
2410
- return json3({ error: "id required" }, 400);
2411
- return deleteVersion(b.id) ? json3({ ok: true }) : json3({ error: "not found" }, 404);
3511
+ return json12({ error: "id required" }, 400);
3512
+ return deleteVersion(b.id) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
2412
3513
  }
2413
3514
  return null;
2414
3515
  }
@@ -2419,7 +3520,7 @@ async function modulesRoutes(req, url) {
2419
3520
  if (p === "/api/modules" && req.method === "GET") {
2420
3521
  const cfg = getConfig();
2421
3522
  const src = getConfigSource();
2422
- return json3({
3523
+ return json12({
2423
3524
  ...moduleSurface(cfg.tools),
2424
3525
  filter: cfg.tools,
2425
3526
  source: src,
@@ -2429,11 +3530,11 @@ async function modulesRoutes(req, url) {
2429
3530
  if (p === "/api/modules/toggle" && req.method === "POST") {
2430
3531
  const b = await readJson(req);
2431
3532
  if (typeof b?.slug !== "string" || typeof b?.enabled !== "boolean") {
2432
- return json3({ error: "slug (string) and enabled (boolean) are required" }, 400);
3533
+ return json12({ error: "slug (string) and enabled (boolean) are required" }, 400);
2433
3534
  }
2434
3535
  const mod = moduleCatalog.find((m) => m.slug.toLowerCase() === b.slug.toLowerCase());
2435
3536
  if (!mod)
2436
- return json3({ error: `unknown module: ${b.slug}` }, 404);
3537
+ return json12({ error: `unknown module: ${b.slug}` }, 404);
2437
3538
  const cfg = getConfig();
2438
3539
  const nextTools = ToolFilterSchema.parse(applyModuleToggle(cfg.tools, mod.slug, b.enabled));
2439
3540
  const next = { ...cfg, tools: nextTools };
@@ -2449,7 +3550,7 @@ async function modulesRoutes(req, url) {
2449
3550
  if (persisted) {
2450
3551
  recordVersion(getConfig(), "auto", Date.now(), `module ${b.enabled ? "enabled" : "disabled"}: ${mod.slug}`);
2451
3552
  }
2452
- return json3({
3553
+ return json12({
2453
3554
  ok: true,
2454
3555
  persisted,
2455
3556
  requiresReconnect: true,
@@ -2464,7 +3565,7 @@ async function modulesRoutes(req, url) {
2464
3565
  if (p === "/api/modules/app-views" && req.method === "POST") {
2465
3566
  const b = await readJson(req);
2466
3567
  if (typeof b?.enabled !== "boolean") {
2467
- return json3({ error: "enabled (boolean) is required" }, 400);
3568
+ return json12({ error: "enabled (boolean) is required" }, 400);
2468
3569
  }
2469
3570
  const cfg = getConfig();
2470
3571
  const next = { ...cfg, mcp: { ...cfg.mcp, appViews: b.enabled } };
@@ -2480,7 +3581,7 @@ async function modulesRoutes(req, url) {
2480
3581
  if (persisted) {
2481
3582
  recordVersion(getConfig(), "auto", Date.now(), `app views ${b.enabled ? "enabled" : "disabled"}`);
2482
3583
  }
2483
- return json3({
3584
+ return json12({
2484
3585
  ok: true,
2485
3586
  persisted,
2486
3587
  requiresReconnect: true,
@@ -2494,11 +3595,11 @@ async function modulesRoutes(req, url) {
2494
3595
  async function captureRoutes(req, url) {
2495
3596
  const p = url.pathname;
2496
3597
  if (p === "/api/capture/status" && req.method === "GET") {
2497
- return json3(capture.stats());
3598
+ return json12(capture.stats());
2498
3599
  }
2499
3600
  if (p === "/api/capture/packets" && req.method === "GET") {
2500
3601
  const limit = Number(url.searchParams.get("limit") ?? 200) || 200;
2501
- return json3({ packets: capture.recent(limit), stats: capture.stats() });
3602
+ return json12({ packets: capture.recent(limit), stats: capture.stats() });
2502
3603
  }
2503
3604
  if (p === "/api/capture/pcap" && req.method === "GET") {
2504
3605
  return new Response(capture.pcap(), {
@@ -2510,11 +3611,11 @@ async function captureRoutes(req, url) {
2510
3611
  }
2511
3612
  if (p === "/api/capture/start" && req.method === "POST") {
2512
3613
  const body = await readJson(req);
2513
- return json3(await capture.start(typeof body?.port === "number" ? body.port : DEFAULT_TZSP_PORT));
3614
+ return json12(await capture.start(typeof body?.port === "number" ? body.port : DEFAULT_TZSP_PORT));
2514
3615
  }
2515
3616
  if (p === "/api/capture/stop" && req.method === "POST") {
2516
3617
  capture.stop();
2517
- return json3({ ok: true, ...capture.stats() });
3618
+ return json12({ ok: true, ...capture.stats() });
2518
3619
  }
2519
3620
  return null;
2520
3621
  }
@@ -2526,24 +3627,24 @@ async function capsmanRoutes(req, url) {
2526
3627
  if (req.method === "GET") {
2527
3628
  const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
2528
3629
  if (p === "/api/capsman/overview") {
2529
- return json3(capsmanOverview(await fetchCapsmanState(ctx)));
3630
+ return json12(capsmanOverview(await fetchCapsmanState(ctx)));
2530
3631
  }
2531
3632
  if (p === "/api/capsman/clients") {
2532
3633
  const state = await fetchCapsmanState(ctx);
2533
3634
  const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
2534
3635
  const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
2535
- return json3({ clients: state.clients, weak });
3636
+ return json12({ clients: state.clients, weak });
2536
3637
  }
2537
3638
  if (p === "/api/capsman/audit") {
2538
- return json3(runCapsmanAudit(await fetchCapsmanState(ctx)));
3639
+ return json12(runCapsmanAudit(await fetchCapsmanState(ctx)));
2539
3640
  }
2540
3641
  if (p === "/api/capsman/trends") {
2541
3642
  if (!capsmanStore)
2542
- return json3({ error: "capsman store not active" }, 503);
3643
+ return json12({ error: "capsman store not active" }, 503);
2543
3644
  const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2544
3645
  const days = daysParam(url, 7, 30);
2545
3646
  const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
2546
- return json3({ series, days });
3647
+ return json12({ series, days });
2547
3648
  }
2548
3649
  return null;
2549
3650
  }
@@ -2554,45 +3655,45 @@ async function capsmanRoutes(req, url) {
2554
3655
  if (p === "/api/capsman/apply/steer") {
2555
3656
  const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
2556
3657
  if (!client)
2557
- return json3({ ok: false, error: "client not associated" }, 400);
3658
+ return json12({ ok: false, error: "client not associated" }, 400);
2558
3659
  if (steerAlreadyPresent(state, body.mac ?? "")) {
2559
- return json3({ ok: true, message: "already steered (no-op)" });
3660
+ return json12({ ok: true, message: "already steered (no-op)" });
2560
3661
  }
2561
3662
  const mode = body.mode === "soft" ? "soft" : "hard";
2562
3663
  const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
2563
3664
  if (!body.confirm)
2564
- return json3({ ok: true, preview: commands });
2565
- return json3(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
3665
+ return json12({ ok: true, preview: commands });
3666
+ return json12(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
2566
3667
  }
2567
3668
  if (p === "/api/capsman/apply/load-balance") {
2568
3669
  const plan = loadBalancePlan(state);
2569
3670
  const commands = buildLoadBalanceCommands(state, plan);
2570
3671
  if (!body.confirm)
2571
- return json3({ ok: true, preview: commands, plan });
2572
- return json3(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
3672
+ return json12({ ok: true, preview: commands, plan });
3673
+ return json12(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
2573
3674
  }
2574
3675
  if (p === "/api/capsman/apply/channel-plan") {
2575
3676
  if (state.path === "/caps-man") {
2576
- return json3({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
3677
+ return json12({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
2577
3678
  }
2578
3679
  const commands = buildChannelPlanCommands(state);
2579
3680
  if (!body.confirm)
2580
- return json3({ ok: true, preview: commands });
2581
- return json3(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
3681
+ return json12({ ok: true, preview: commands });
3682
+ return json12(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
2582
3683
  }
2583
3684
  if (p === "/api/capsman/apply/ft") {
2584
3685
  const commands = buildFtCommands(state);
2585
3686
  if (!body.confirm)
2586
- return json3({ ok: true, preview: commands });
2587
- return json3(await applyCapsmanWrites(ctx, commands, "pre-ft"));
3687
+ return json12({ ok: true, preview: commands });
3688
+ return json12(await applyCapsmanWrites(ctx, commands, "pre-ft"));
2588
3689
  }
2589
3690
  if (p === "/api/capsman/apply/ha") {
2590
3691
  const commands = buildHaCommands(state);
2591
3692
  const guidance = haGuidance(state);
2592
3693
  if (!body.confirm)
2593
- return json3({ ok: true, preview: commands, guidance });
3694
+ return json12({ ok: true, preview: commands, guidance });
2594
3695
  const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
2595
- return json3({ ...res, guidance });
3696
+ return json12({ ...res, guidance });
2596
3697
  }
2597
3698
  }
2598
3699
  return null;
@@ -2618,32 +3719,32 @@ async function clientsRoutes(req, url) {
2618
3719
  const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
2619
3720
  if (p === "/api/clients" && req.method === "GET") {
2620
3721
  const ctx = createContext(undefined, deviceFromQuery());
2621
- return json3(devicesView(await fetchDevices(ctx)));
3722
+ return json12(devicesView(await fetchDevices(ctx)));
2622
3723
  }
2623
3724
  if (p === "/api/clients/traffic" && req.method === "GET") {
2624
3725
  const ip = url.searchParams.get("ip");
2625
3726
  if (!ip)
2626
- return json3({ error: "ip required" }, 400);
3727
+ return json12({ error: "ip required" }, 400);
2627
3728
  const ctx = createContext(undefined, deviceFromQuery());
2628
- return json3(await sampleDeviceTraffic(ctx, ip));
3729
+ return json12(await sampleDeviceTraffic(ctx, ip));
2629
3730
  }
2630
3731
  if (p === "/api/clients/traffic-bulk" && req.method === "GET") {
2631
3732
  const ctx = createContext(undefined, deviceFromQuery());
2632
- return json3(await sampleAllTraffic(ctx));
3733
+ return json12(await sampleAllTraffic(ctx));
2633
3734
  }
2634
3735
  if (req.method === "POST") {
2635
3736
  const b = await readJson(req);
2636
3737
  const ctx = createContext(undefined, b?.device);
2637
3738
  if (p === "/api/clients/limits") {
2638
3739
  if (!b?.ip)
2639
- return json3({ error: "ip required" }, 400);
2640
- return json3(await setDeviceLimits(ctx, b.ip, { download: b.download, upload: b.upload }));
3740
+ return json12({ error: "ip required" }, 400);
3741
+ return json12(await setDeviceLimits(ctx, b.ip, { download: b.download, upload: b.upload }));
2641
3742
  }
2642
3743
  if (!b?.mac)
2643
- return json3({ error: "mac required" }, 400);
3744
+ return json12({ error: "mac required" }, 400);
2644
3745
  const run = async (op) => {
2645
3746
  const r = await op;
2646
- return json3({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
3747
+ return json12({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
2647
3748
  };
2648
3749
  if (p === "/api/clients/block")
2649
3750
  return run(blockDevice(ctx, b.mac, b.comment));
@@ -2655,12 +3756,12 @@ async function clientsRoutes(req, url) {
2655
3756
  return run(removeDeviceLease(ctx, b.mac));
2656
3757
  if (p === "/api/clients/set-ip") {
2657
3758
  if (!b.ip)
2658
- return json3({ error: "ip required" }, 400);
3759
+ return json12({ error: "ip required" }, 400);
2659
3760
  return run(setDeviceIp(ctx, b.mac, b.ip));
2660
3761
  }
2661
3762
  if (p === "/api/clients/label") {
2662
3763
  if (typeof b.label !== "string")
2663
- return json3({ error: "label required" }, 400);
3764
+ return json12({ error: "label required" }, 400);
2664
3765
  return run(setDeviceLabel(ctx, b.mac, b.label));
2665
3766
  }
2666
3767
  }
@@ -2673,20 +3774,20 @@ async function aaaRoutes(req, url) {
2673
3774
  const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
2674
3775
  if (req.method === "GET") {
2675
3776
  if (p === "/api/aaa/entities") {
2676
- return json3({ entities: AAA_ENTITIES });
3777
+ return json12({ entities: AAA_ENTITIES });
2677
3778
  }
2678
3779
  if (p === "/api/aaa/radius-incoming") {
2679
- return json3(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
3780
+ return json12(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
2680
3781
  }
2681
3782
  if (p === "/api/aaa/um-settings") {
2682
- return json3(await getUmSettings(createContext(undefined, deviceFromQuery())));
3783
+ return json12(await getUmSettings(createContext(undefined, deviceFromQuery())));
2683
3784
  }
2684
3785
  const listMatch = p.match(/^\/api\/aaa\/list\/([\w-]+)$/);
2685
3786
  if (listMatch) {
2686
3787
  const slug = listMatch[1];
2687
3788
  if (!AAA_ENTITIES[slug])
2688
- return json3({ error: "unknown entity" }, 404);
2689
- return json3(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
3789
+ return json12({ error: "unknown entity" }, 404);
3790
+ return json12(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
2690
3791
  }
2691
3792
  return null;
2692
3793
  }
@@ -2694,30 +3795,30 @@ async function aaaRoutes(req, url) {
2694
3795
  const body = await readJson(req);
2695
3796
  const ctx = createContext(undefined, body?.device);
2696
3797
  if (p === "/api/aaa/radius-incoming")
2697
- return json3(await setRadiusIncoming(ctx, body?.fields ?? {}));
3798
+ return json12(await setRadiusIncoming(ctx, body?.fields ?? {}));
2698
3799
  if (p === "/api/aaa/um-settings")
2699
- return json3(await setUmSettings(ctx, body?.fields ?? {}));
3800
+ return json12(await setUmSettings(ctx, body?.fields ?? {}));
2700
3801
  if (p === "/api/aaa/radius-reset-counters")
2701
- return json3(await resetRadiusCounters(ctx));
3802
+ return json12(await resetRadiusCounters(ctx));
2702
3803
  const slug = body?.slug ?? "";
2703
3804
  if (!AAA_ENTITIES[slug])
2704
- return json3({ error: "unknown entity" }, 404);
3805
+ return json12({ error: "unknown entity" }, 404);
2705
3806
  if (p === "/api/aaa/add")
2706
- return json3(await addAaaEntity(ctx, slug, body?.fields ?? {}));
3807
+ return json12(await addAaaEntity(ctx, slug, body?.fields ?? {}));
2707
3808
  if (p === "/api/aaa/update") {
2708
3809
  if (!body?.id)
2709
- return json3({ error: "id required" }, 400);
2710
- return json3(await updateAaaEntity(ctx, slug, body.id, body?.fields ?? {}));
3810
+ return json12({ error: "id required" }, 400);
3811
+ return json12(await updateAaaEntity(ctx, slug, body.id, body?.fields ?? {}));
2711
3812
  }
2712
3813
  if (p === "/api/aaa/remove") {
2713
3814
  if (!body?.id)
2714
- return json3({ error: "id required" }, 400);
2715
- return json3(await removeAaaEntity(ctx, slug, body.id));
3815
+ return json12({ error: "id required" }, 400);
3816
+ return json12(await removeAaaEntity(ctx, slug, body.id));
2716
3817
  }
2717
3818
  if (p === "/api/aaa/toggle") {
2718
3819
  if (!body?.id)
2719
- return json3({ error: "id required" }, 400);
2720
- return json3(await toggleAaaEntity(ctx, slug, body.id, body?.enable === true));
3820
+ return json12({ error: "id required" }, 400);
3821
+ return json12(await toggleAaaEntity(ctx, slug, body.id, body?.enable === true));
2721
3822
  }
2722
3823
  }
2723
3824
  return null;
@@ -2733,43 +3834,43 @@ async function usageRoutes(req, url) {
2733
3834
  return null;
2734
3835
  if (p === "/api/usage/sampler") {
2735
3836
  if (req.method === "GET")
2736
- return json3({ intervalMs: getUsageSamplerInterval() });
3837
+ return json12({ intervalMs: getUsageSamplerInterval() });
2737
3838
  if (req.method === "POST") {
2738
3839
  const b = await readJson(req);
2739
3840
  const applied = setUsageSamplerInterval(Number(b?.intervalMs));
2740
- return json3({ intervalMs: applied });
3841
+ return json12({ intervalMs: applied });
2741
3842
  }
2742
3843
  return null;
2743
3844
  }
2744
3845
  if (req.method !== "GET")
2745
3846
  return null;
2746
3847
  if (!usageStore)
2747
- return json3({ error: "usage store not active" }, 503);
3848
+ return json12({ error: "usage store not active" }, 503);
2748
3849
  const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2749
3850
  const sinceTs = (days) => Date.now() - days * 86400000;
2750
3851
  if (p === "/api/usage/um-users") {
2751
- return json3({ users: usageStore.umUsers(device) });
3852
+ return json12({ users: usageStore.umUsers(device) });
2752
3853
  }
2753
3854
  if (p === "/api/usage/client") {
2754
3855
  const ip = url.searchParams.get("ip");
2755
3856
  if (!ip)
2756
- return json3({ error: "ip required" }, 400);
3857
+ return json12({ error: "ip required" }, 400);
2757
3858
  const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
2758
- return json3(withTotals(series));
3859
+ return json12(withTotals(series));
2759
3860
  }
2760
3861
  if (p === "/api/usage/um-user") {
2761
3862
  const user = url.searchParams.get("user");
2762
3863
  if (!user)
2763
- return json3({ error: "user required" }, 400);
3864
+ return json12({ error: "user required" }, 400);
2764
3865
  const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
2765
- return json3(withTotals(series));
3866
+ return json12(withTotals(series));
2766
3867
  }
2767
3868
  if (p === "/api/usage/heatmap") {
2768
3869
  const user = url.searchParams.get("user");
2769
3870
  const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
2770
3871
  const total = days.reduce((s, d) => s + d.count, 0);
2771
3872
  const max = days.reduce((m, d) => Math.max(m, d.count), 0);
2772
- return json3({ days, total, max });
3873
+ return json12({ days, total, max });
2773
3874
  }
2774
3875
  return null;
2775
3876
  }
@@ -2789,31 +3890,31 @@ function snapStore() {
2789
3890
  async function featureRoutes(req, url) {
2790
3891
  const p = url.pathname;
2791
3892
  if (p === "/api/snapshots" && req.method === "GET") {
2792
- const store = await snapStore();
3893
+ const store3 = await snapStore();
2793
3894
  const cfg = getConfig();
2794
- const all = Object.keys(cfg.devices).flatMap((d) => store.list(d, 200, false));
3895
+ const all = Object.keys(cfg.devices).flatMap((d) => store3.list(d, 200, false));
2795
3896
  all.sort((a, b) => b.ts - a.ts);
2796
- return json3({ snapshots: all });
3897
+ return json12({ snapshots: all });
2797
3898
  }
2798
3899
  const snapMatch = p.match(/^\/api\/snapshot\/(.+)$/);
2799
3900
  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);
3901
+ const store3 = await snapStore();
3902
+ const s = store3.get(decodeURIComponent(snapMatch[1]));
3903
+ return s ? json12(s) : json12({ error: "not found" }, 404);
2803
3904
  }
2804
3905
  if (p === "/api/snapshots/diff" && req.method === "POST") {
2805
- const store = await snapStore();
3906
+ const store3 = await snapStore();
2806
3907
  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;
3908
+ const from = b?.from ? store3.get(b.from) : null;
3909
+ const to = b?.to ? store3.get(b.to) : null;
2809
3910
  if (!from || !to) {
2810
- return json3({ error: "both 'from' and 'to' snapshot ids are required" }, 400);
3911
+ return json12({ error: "both 'from' and 'to' snapshot ids are required" }, 400);
2811
3912
  }
2812
3913
  const diff = diffLines(normalizeExport(from.body), normalizeExport(to.body), {
2813
3914
  fromLabel: from.label ?? from.id,
2814
3915
  toLabel: to.label ?? to.id
2815
3916
  });
2816
- return json3({
3917
+ return json12({
2817
3918
  from: { id: from.id, label: from.label, ts: from.ts, device: from.device },
2818
3919
  to: { id: to.id, label: to.label, ts: to.ts, device: to.device },
2819
3920
  summary: diff.summary,
@@ -2827,16 +3928,16 @@ async function featureRoutes(req, url) {
2827
3928
  ...b?.script ? splitCommands(b.script) : []
2828
3929
  ];
2829
3930
  if (commands.length === 0)
2830
- return json3({ error: "no commands provided" }, 400);
3931
+ return json12({ error: "no commands provided" }, 400);
2831
3932
  const plan = buildChangePlan(commands);
2832
- return json3({ plan, text: renderPlan(plan) });
3933
+ return json12({ plan, text: renderPlan(plan) });
2833
3934
  }
2834
3935
  if (p === "/api/s3" && req.method === "GET") {
2835
- return json3({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
3936
+ return json12({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
2836
3937
  }
2837
3938
  if (p === "/api/s3/list" && req.method === "GET") {
2838
3939
  if (!isS3Configured())
2839
- return json3({ configured: false, objects: [] });
3940
+ return json12({ configured: false, objects: [] });
2840
3941
  const prefix = url.searchParams.get("prefix") ?? "";
2841
3942
  try {
2842
3943
  const res = await getS3Client().list({ prefix: prefix || undefined, maxKeys: 1000 });
@@ -2845,42 +3946,42 @@ async function featureRoutes(req, url) {
2845
3946
  size: o.size ?? 0,
2846
3947
  lastModified: o.lastModified ?? null
2847
3948
  }));
2848
- return json3({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
3949
+ return json12({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
2849
3950
  } catch (e) {
2850
- return json3({ error: clientError(e) }, 502);
3951
+ return json12({ error: clientError(e) }, 502);
2851
3952
  }
2852
3953
  }
2853
3954
  if (p === "/api/s3/presign" && req.method === "GET") {
2854
3955
  if (!isS3Configured())
2855
- return json3({ error: "S3 not configured" }, 400);
3956
+ return json12({ error: "S3 not configured" }, 400);
2856
3957
  const key = url.searchParams.get("key");
2857
3958
  if (!key)
2858
- return json3({ error: "key required" }, 400);
3959
+ return json12({ error: "key required" }, 400);
2859
3960
  try {
2860
3961
  const link = getS3Client().presign(key, { expiresIn: presignExpiresIn(), method: "GET" });
2861
- return json3({ url: link });
3962
+ return json12({ url: link });
2862
3963
  } catch (e) {
2863
- return json3({ error: clientError(e) }, 502);
3964
+ return json12({ error: clientError(e) }, 502);
2864
3965
  }
2865
3966
  }
2866
3967
  if (p === "/api/s3/delete" && req.method === "POST") {
2867
3968
  if (!isS3Configured())
2868
- return json3({ error: "S3 not configured" }, 400);
3969
+ return json12({ error: "S3 not configured" }, 400);
2869
3970
  const b = await readJson(req);
2870
3971
  if (!b?.key)
2871
- return json3({ error: "key required" }, 400);
3972
+ return json12({ error: "key required" }, 400);
2872
3973
  try {
2873
3974
  const client = getS3Client();
2874
3975
  if (!await client.exists(b.key))
2875
- return json3({ error: "not found" }, 404);
3976
+ return json12({ error: "not found" }, 404);
2876
3977
  await client.delete(b.key);
2877
- return json3({ ok: true, key: b.key });
3978
+ return json12({ ok: true, key: b.key });
2878
3979
  } catch (e) {
2879
- return json3({ error: clientError(e) }, 502);
3980
+ return json12({ error: clientError(e) }, 502);
2880
3981
  }
2881
3982
  }
2882
3983
  if (p === "/api/backups" && req.method === "GET") {
2883
- return json3({
3984
+ return json12({
2884
3985
  dir: backupDir(),
2885
3986
  devices: Object.keys(getConfig().devices),
2886
3987
  backups: listBackups()
@@ -2890,14 +3991,14 @@ async function featureRoutes(req, url) {
2890
3991
  const b = await readJson(req);
2891
3992
  const raw = b?.dir?.trim();
2892
3993
  if (!raw)
2893
- return json3({ error: "dir required" }, 400);
3994
+ return json12({ error: "dir required" }, 400);
2894
3995
  const dir = raw === "~" || raw.startsWith("~/") ? join4(homedir3(), raw.slice(1)) : raw;
2895
3996
  const next = { ...getConfig(), backupDir: dir };
2896
3997
  setConfig(next);
2897
3998
  try {
2898
3999
  atomicWrite(getConfigSource().path, serializeConfig(next));
2899
4000
  } catch (e) {
2900
- return json3({
4001
+ return json12({
2901
4002
  ok: true,
2902
4003
  dir: backupDir(),
2903
4004
  persisted: false,
@@ -2905,16 +4006,16 @@ async function featureRoutes(req, url) {
2905
4006
  });
2906
4007
  }
2907
4008
  recordVersion(getConfig(), "auto", Date.now(), "backup path changed");
2908
- return json3({ ok: true, dir: backupDir(), persisted: true });
4009
+ return json12({ ok: true, dir: backupDir(), persisted: true });
2909
4010
  }
2910
4011
  if (p === "/api/backups/get" && req.method === "GET") {
2911
4012
  const name = url.searchParams.get("name");
2912
4013
  if (!name)
2913
- return json3({ error: "name required" }, 400);
4014
+ return json12({ error: "name required" }, 400);
2914
4015
  try {
2915
- return json3({ name, content: readBackup(name) });
4016
+ return json12({ name, content: readBackup(name) });
2916
4017
  } catch {
2917
- return json3({ error: "not found" }, 404);
4018
+ return json12({ error: "not found" }, 404);
2918
4019
  }
2919
4020
  }
2920
4021
  if (p === "/api/backups/raw" && req.method === "GET") {
@@ -2935,41 +4036,41 @@ async function featureRoutes(req, url) {
2935
4036
  if (p === "/api/backups/upload" && req.method === "POST") {
2936
4037
  const b = await readJson(req);
2937
4038
  if (!b?.name || typeof b.content !== "string") {
2938
- return json3({ error: "name and content are required" }, 400);
4039
+ return json12({ error: "name and content are required" }, 400);
2939
4040
  }
2940
4041
  try {
2941
4042
  const safe = b.name.endsWith(".rsc") ? b.name : `${b.name}.rsc`;
2942
- return json3({ ok: true, name: writeBackup(safe, b.content) });
4043
+ return json12({ ok: true, name: writeBackup(safe, b.content) });
2943
4044
  } catch (e) {
2944
- return json3({ error: clientError(e) }, 400);
4045
+ return json12({ error: clientError(e) }, 400);
2945
4046
  }
2946
4047
  }
2947
4048
  if (p === "/api/backups/rename" && req.method === "POST") {
2948
4049
  const b = await readJson(req);
2949
4050
  if (!b?.name || !b?.new_name)
2950
- return json3({ error: "name and new_name are required" }, 400);
4051
+ return json12({ error: "name and new_name are required" }, 400);
2951
4052
  try {
2952
- return json3({ ok: true, name: renameBackup(b.name, b.new_name) });
4053
+ return json12({ ok: true, name: renameBackup(b.name, b.new_name) });
2953
4054
  } catch (e) {
2954
- return json3({ error: clientError(e) }, 400);
4055
+ return json12({ error: clientError(e) }, 400);
2955
4056
  }
2956
4057
  }
2957
4058
  if (p === "/api/backups/delete" && req.method === "POST") {
2958
4059
  const b = await readJson(req);
2959
4060
  if (!b?.name)
2960
- return json3({ error: "name required" }, 400);
4061
+ return json12({ error: "name required" }, 400);
2961
4062
  try {
2962
- return deleteBackup(b.name) ? json3({ ok: true }) : json3({ error: "not found" }, 404);
4063
+ return deleteBackup(b.name) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
2963
4064
  } catch (e) {
2964
- return json3({ error: clientError(e) }, 400);
4065
+ return json12({ error: clientError(e) }, 400);
2965
4066
  }
2966
4067
  }
2967
4068
  if (p === "/api/backups/restore" && req.method === "POST") {
2968
4069
  const b = await readJson(req);
2969
4070
  if (!b?.name)
2970
- return json3({ error: "name required" }, 400);
4071
+ return json12({ error: "name required" }, 400);
2971
4072
  const device = resolveDeviceName(b.device);
2972
- return json3(await restoreLocalBackup(device, b.name, b.confirm === true));
4073
+ return json12(await restoreLocalBackup(device, b.name, b.confirm === true));
2973
4074
  }
2974
4075
  if (p === "/api/backups/create" && req.method === "POST") {
2975
4076
  const b = await readJson(req);
@@ -2981,14 +4082,14 @@ async function featureRoutes(req, url) {
2981
4082
  compact: b?.compact === true,
2982
4083
  terse: b?.terse === true
2983
4084
  });
2984
- return r.ok ? json3(r) : json3({ error: r.error ?? "export failed" }, 502);
4085
+ return r.ok ? json12(r) : json12({ error: r.error ?? "export failed" }, 502);
2985
4086
  }
2986
4087
  return null;
2987
4088
  }
2988
4089
  async function runDashboard(cfg, transportLabel) {
2989
- const store = await openSqliteStore(cfg.dbPath);
4090
+ const store3 = await openSqliteStore(cfg.dbPath);
2990
4091
  configureRecorder({
2991
- store,
4092
+ store: store3,
2992
4093
  capture: {
2993
4094
  captureBody: cfg.captureBody,
2994
4095
  maxBodyBytes: cfg.maxBodyBytes,
@@ -3000,13 +4101,13 @@ async function runDashboard(cfg, transportLabel) {
3000
4101
  startHealthChecks(30000);
3001
4102
  startGeoLookups();
3002
4103
  try {
3003
- usageStore = await openUsageStore(join4(dirname5(cfg.dbPath), "usage.db"));
4104
+ usageStore = await openUsageStore(join4(dirname7(cfg.dbPath), "usage.db"));
3004
4105
  startUsageSampler(usageStore);
3005
4106
  } catch (e) {
3006
4107
  logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
3007
4108
  }
3008
4109
  try {
3009
- capsmanStore = await openCapsmanStore(join4(dirname5(cfg.dbPath), "capsman.db"));
4110
+ capsmanStore = await openCapsmanStore(join4(dirname7(cfg.dbPath), "capsman.db"));
3010
4111
  startCapsmanSampler(capsmanStore);
3011
4112
  } catch (e) {
3012
4113
  logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
@@ -3080,12 +4181,39 @@ async function runDashboard(cfg, transportLabel) {
3080
4181
  const driftResp = await driftRoutes(req, url);
3081
4182
  if (driftResp)
3082
4183
  return driftResp;
4184
+ const txnResp = await txnRoutes(req, url);
4185
+ if (txnResp)
4186
+ return txnResp;
4187
+ const flowResp = await flowRoutes(req, url);
4188
+ if (flowResp)
4189
+ return flowResp;
4190
+ const rolloutResp = await rolloutRoutes(req, url);
4191
+ if (rolloutResp)
4192
+ return rolloutResp;
4193
+ const policyResp = await policyRoutes(req, url);
4194
+ if (policyResp)
4195
+ return policyResp;
4196
+ const simResp = await simRoutes(req, url);
4197
+ if (simResp)
4198
+ return simResp;
4199
+ const scheduleResp = await scheduleRoutes(req, url);
4200
+ if (scheduleResp)
4201
+ return scheduleResp;
4202
+ const explainResp = await explainRoutes(req, url);
4203
+ if (explainResp)
4204
+ return explainResp;
4205
+ const attackResp = await attackRoutes(req, url);
4206
+ if (attackResp)
4207
+ return attackResp;
4208
+ const alertResp = await alertRoutes(req, url, getEventStore());
4209
+ if (alertResp)
4210
+ return alertResp;
3083
4211
  const memoryResp = await memoryRoutes(req, url);
3084
4212
  if (memoryResp)
3085
4213
  return memoryResp;
3086
4214
  const db = getEventStore();
3087
4215
  if (!db)
3088
- return json3({ error: "recorder not active" }, 503);
4216
+ return json12({ error: "recorder not active" }, 503);
3089
4217
  if (url.pathname === "/api/events" && req.method === "DELETE") {
3090
4218
  let body = {};
3091
4219
  try {
@@ -3093,7 +4221,7 @@ async function runDashboard(cfg, transportLabel) {
3093
4221
  } catch {}
3094
4222
  const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
3095
4223
  const removed = body.all === true ? db.clear() : db.delete(ids);
3096
- return json3({ removed, total: db.total() });
4224
+ return json12({ removed, total: db.total() });
3097
4225
  }
3098
4226
  if (url.pathname === "/api/ssh-pool") {
3099
4227
  const cfg2 = getConfig();
@@ -3102,7 +4230,7 @@ async function runDashboard(cfg, transportLabel) {
3102
4230
  const totalInflight = ps.reduce((s, p) => s + p.inflight, 0);
3103
4231
  const totalIdle = ps.filter((p) => p.idle).length;
3104
4232
  const totalBusy = ps.filter((p) => p.inflight > 0).length;
3105
- return json3({
4233
+ return json12({
3106
4234
  enabled,
3107
4235
  config: {
3108
4236
  keepAlive: cfg2.ssh.keepAlive,
@@ -3131,16 +4259,39 @@ async function runDashboard(cfg, transportLabel) {
3131
4259
  });
3132
4260
  }
3133
4261
  if (url.pathname === "/api/devices") {
3134
- return json3(devicesPayload(db));
4262
+ return json12(devicesPayload(db));
4263
+ }
4264
+ if (url.pathname === "/api/capabilities") {
4265
+ const devices = Object.keys(getConfig().devices).map((name) => ({
4266
+ device: name,
4267
+ capabilities: serializeCapabilities(peekCapabilities(name))
4268
+ }));
4269
+ return json12({ devices });
4270
+ }
4271
+ if (url.pathname === "/api/capabilities/refresh" && req.method === "POST") {
4272
+ const b = await readJson(req);
4273
+ if (typeof b?.device !== "string")
4274
+ return json12({ error: "device (string) is required" }, 400);
4275
+ if (!(b.device in getConfig().devices)) {
4276
+ return json12({ error: `unknown device: ${b.device}` }, 404);
4277
+ }
4278
+ invalidateCapabilities(b.device);
4279
+ try {
4280
+ const caps = await getCapabilities(b.device);
4281
+ return json12({ device: b.device, capabilities: serializeCapabilities(caps) });
4282
+ } catch (e) {
4283
+ logger.error(`Capability probe failed for '${b.device}': ${logError(e)}`);
4284
+ return json12({ error: clientError(e) }, 502);
4285
+ }
3135
4286
  }
3136
4287
  if (url.pathname === "/api/devices/toggle" && req.method === "POST") {
3137
4288
  const b = await readJson(req);
3138
4289
  if (typeof b?.device !== "string" || typeof b?.disabled !== "boolean") {
3139
- return json3({ error: "device (string) and disabled (boolean) are required" }, 400);
4290
+ return json12({ error: "device (string) and disabled (boolean) are required" }, 400);
3140
4291
  }
3141
4292
  const cfg2 = getConfig();
3142
4293
  if (!(b.device in cfg2.devices))
3143
- return json3({ error: `unknown device: ${b.device}` }, 404);
4294
+ return json12({ error: `unknown device: ${b.device}` }, 404);
3144
4295
  const dc = cfg2.devices[b.device];
3145
4296
  const next = {
3146
4297
  ...cfg2,
@@ -3161,7 +4312,7 @@ async function runDashboard(cfg, transportLabel) {
3161
4312
  if (persisted) {
3162
4313
  recordVersion(getConfig(), "auto", Date.now(), `device ${b.disabled ? "disabled" : "enabled"}: ${b.device}`);
3163
4314
  }
3164
- return json3({
4315
+ return json12({
3165
4316
  ok: true,
3166
4317
  persisted,
3167
4318
  requiresReconnect: true,
@@ -3174,17 +4325,17 @@ async function runDashboard(cfg, transportLabel) {
3174
4325
  const name = typeof b?.device === "string" ? b.device : "";
3175
4326
  const cfg2 = getConfig();
3176
4327
  if (!(name in cfg2.devices))
3177
- return json3({ error: `unknown device: ${name}` }, 404);
4328
+ return json12({ error: `unknown device: ${name}` }, 404);
3178
4329
  if (url.pathname === "/api/devices/reconnect")
3179
4330
  closeDevice(name);
3180
4331
  const status = await probeDevice(name, cfg2.devices[name]);
3181
- return json3({ ok: true, status, ...devicesPayload(db) });
4332
+ return json12({ ok: true, status, ...devicesPayload(db) });
3182
4333
  }
3183
4334
  if (url.pathname === "/api/topology") {
3184
- return json3(topologyPayload());
4335
+ return json12(topologyPayload());
3185
4336
  }
3186
4337
  if (url.pathname === "/api/config") {
3187
- return json3(configPayload());
4338
+ return json12(configPayload());
3188
4339
  }
3189
4340
  if (url.pathname === "/" || url.pathname === "/index.html") {
3190
4341
  return new Response(dashboardHtml(), {
@@ -3193,9 +4344,9 @@ async function runDashboard(cfg, transportLabel) {
3193
4344
  }
3194
4345
  if (url.pathname === "/api/releases/latest") {
3195
4346
  try {
3196
- return json3(await fetchLatestRelease());
4347
+ return json12(await fetchLatestRelease());
3197
4348
  } catch (e) {
3198
- return json3({ error: clientError(e, "fetch failed") }, 502);
4349
+ return json12({ error: clientError(e, "fetch failed") }, 502);
3199
4350
  }
3200
4351
  }
3201
4352
  if (url.pathname === "/api/catalog" && req.method === "GET") {
@@ -3214,7 +4365,7 @@ async function runDashboard(cfg, transportLabel) {
3214
4365
  }));
3215
4366
  const prompts = listPrompts();
3216
4367
  const groups = [...new Set(modules.map((m) => m.group))].sort();
3217
- return json3({
4368
+ return json12({
3218
4369
  modules,
3219
4370
  prompts,
3220
4371
  groups,
@@ -3229,25 +4380,25 @@ async function runDashboard(cfg, transportLabel) {
3229
4380
  }
3230
4381
  if (url.pathname === "/api/releases" && req.method === "GET") {
3231
4382
  try {
3232
- return json3(await fetchAllReleases());
4383
+ return json12(await fetchAllReleases());
3233
4384
  } catch (e) {
3234
- return json3({ error: clientError(e, "fetch failed") }, 502);
4385
+ return json12({ error: clientError(e, "fetch failed") }, 502);
3235
4386
  }
3236
4387
  }
3237
4388
  if (url.pathname === "/api/upgrade" && req.method === "POST") {
3238
4389
  const body = await readJson(req);
3239
4390
  const version = String(body?.version ?? "latest");
3240
4391
  if (version !== "latest" && !/^\d+\.\d+\.\d+$/.test(version)) {
3241
- return json3({ ok: false, error: `Invalid version "${version}" (use "latest" or x.y.z).` }, 400);
4392
+ return json12({ ok: false, error: `Invalid version "${version}" (use "latest" or x.y.z).` }, 400);
3242
4393
  }
3243
4394
  const spec = `@usex/mikrotik-mcp@${version}`;
3244
4395
  logger.warn(`Dashboard requested upgrade: bun i -g ${spec}`);
3245
4396
  const { ok, log } = await runUpgrade(spec);
3246
4397
  if (!ok)
3247
- return json3({ ok: false, version, log }, 500);
4398
+ return json12({ ok: false, version, log }, 500);
3248
4399
  const willRestart = body?.restart !== false;
3249
4400
  const relaunched = willRestart ? restartProcess() : false;
3250
- return json3({
4401
+ return json12({
3251
4402
  ok: true,
3252
4403
  version,
3253
4404
  log,
@@ -3257,7 +4408,7 @@ async function runDashboard(cfg, transportLabel) {
3257
4408
  }
3258
4409
  if (url.pathname === "/api/meta") {
3259
4410
  const f = facets(db);
3260
- return json3({
4411
+ return json12({
3261
4412
  ...f,
3262
4413
  version: VERSION,
3263
4414
  risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
@@ -3268,19 +4419,19 @@ async function runDashboard(cfg, transportLabel) {
3268
4419
  }
3269
4420
  if (url.pathname === "/api/events") {
3270
4421
  const filter = filterFromQuery(url);
3271
- return json3({ events: db.query(filter), total: db.total() });
4422
+ return json12({ events: db.query(filter), total: db.total() });
3272
4423
  }
3273
4424
  const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
3274
4425
  if (eventMatch) {
3275
4426
  const e = db.get(decodeURIComponent(eventMatch[1]));
3276
- return e ? json3(e) : json3({ error: "not found" }, 404);
4427
+ return e ? json12(e) : json12({ error: "not found" }, 404);
3277
4428
  }
3278
4429
  if (url.pathname === "/api/stats") {
3279
4430
  const now = Date.now();
3280
- const windowMs = Number(url.searchParams.get("window") ?? 3600000);
4431
+ const windowMs2 = Number(url.searchParams.get("window") ?? 3600000);
3281
4432
  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 }));
4433
+ const events = db.query({ since: now - windowMs2, limit: 5000 });
4434
+ return json12(computeStats(events, { now, windowMs: windowMs2, buckets }));
3284
4435
  }
3285
4436
  return new Response("Not Found", { status: 404 });
3286
4437
  }
@@ -3299,7 +4450,7 @@ async function runDashboard(cfg, transportLabel) {
3299
4450
  return await dashboardRoute(req, url, srv, configAdmin, transportLabel);
3300
4451
  } catch (e) {
3301
4452
  logger.error(`Dashboard request failed (${url.pathname}): ${logError(e)}`);
3302
- return json3({ error: clientError(e) }, 502);
4453
+ return json12({ error: clientError(e) }, 502);
3303
4454
  }
3304
4455
  },
3305
4456
  websocket: {
@@ -3311,11 +4462,19 @@ async function runDashboard(cfg, transportLabel) {
3311
4462
  } catch {}
3312
4463
  });
3313
4464
  } else {
3314
- ws.data.unsub = subscribe((e) => {
4465
+ const send = (payload2) => {
3315
4466
  try {
3316
- ws.send(JSON.stringify({ type: "event", event: e }));
4467
+ ws.send(JSON.stringify(payload2));
3317
4468
  } catch {}
3318
- });
4469
+ };
4470
+ const unsubEvents = subscribe((e) => send({ type: "event", event: e }));
4471
+ const unsubTxn = subscribeTxn((update) => send({ type: "txn", update }));
4472
+ const unsubRollout = subscribeRollout((update) => send({ type: "rollout", update }));
4473
+ ws.data.unsub = () => {
4474
+ unsubEvents();
4475
+ unsubTxn();
4476
+ unsubRollout();
4477
+ };
3319
4478
  }
3320
4479
  ws.send(JSON.stringify({ type: "hello", transport: transportLabel }));
3321
4480
  },
@@ -3336,14 +4495,14 @@ async function runDashboard(cfg, transportLabel) {
3336
4495
  }
3337
4496
  return {
3338
4497
  server,
3339
- store,
4498
+ store: store3,
3340
4499
  stop() {
3341
4500
  stopHealthChecks();
3342
4501
  stopGeoLookups();
3343
4502
  stopUsageSampler();
3344
4503
  stopCapsmanSampler();
3345
4504
  server.stop(true);
3346
- store.close();
4505
+ store3.close();
3347
4506
  usageStore?.close();
3348
4507
  usageStore = null;
3349
4508
  capsmanStore?.close();
@@ -3660,6 +4819,12 @@ async function runStdio() {
3660
4819
  const transport = new StdioServerTransport;
3661
4820
  await server.connect(transport);
3662
4821
  logger.info(`MCP server ready on stdio \u2014 ${toolCount} tools, ${promptCount} prompts, ${uiViewCount} app views${readOnly ? " [READ-ONLY]" : ""}`);
4822
+ setMcpAlertSender((n) => {
4823
+ server.server.sendLoggingMessage({
4824
+ level: n.severity === "critical" || n.severity === "high" ? "error" : "info",
4825
+ data: `[alert:${n.kind}] ${n.title}${n.device ? ` \xB7 ${n.device}` : ""} ` + `(severity ${n.severity}, rule ${n.ruleId})${n.body ? ` \u2014 ${n.body}` : ""}`
4826
+ }).catch(() => {});
4827
+ });
3663
4828
  if (!getConfig().disableUpdateCheck) {
3664
4829
  whisperUpdate(server).catch(() => {});
3665
4830
  }
@@ -3730,6 +4895,100 @@ async function printBanner() {
3730
4895
  }
3731
4896
  }
3732
4897
 
4898
+ // src/alerts/sampler.ts
4899
+ var DEFAULT_ALERT_SAMPLE_MS = 30000;
4900
+ var timer4 = null;
4901
+ var snapshotStorePromise = null;
4902
+ function getSnapshotStore() {
4903
+ snapshotStorePromise ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
4904
+ return snapshotStorePromise;
4905
+ }
4906
+ function computeMetricSample(windowMs2, now = Date.now()) {
4907
+ const store3 = getEventStore();
4908
+ if (!store3)
4909
+ return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
4910
+ const events = store3.query({ since: now - windowMs2, limit: 1e4 });
4911
+ const calls = events.length;
4912
+ if (calls === 0)
4913
+ return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
4914
+ const durations = events.map((e) => e.durationMs);
4915
+ return {
4916
+ calls,
4917
+ errors: events.reduce((n, e) => n + (e.isError ? 1 : 0), 0),
4918
+ avgDurationMs: durations.reduce((a, b) => a + b, 0) / calls,
4919
+ p95DurationMs: percentile(durations, 95)
4920
+ };
4921
+ }
4922
+ async function lastSeen(t) {
4923
+ switch (t.absence) {
4924
+ case "tool_call": {
4925
+ const store3 = getEventStore();
4926
+ if (!store3)
4927
+ return;
4928
+ const rows = store3.query({
4929
+ limit: 1,
4930
+ tool: t.tool?.length === 1 ? t.tool[0] : undefined,
4931
+ device: t.device?.length === 1 ? t.device[0] : undefined
4932
+ });
4933
+ return rows[0]?.ts;
4934
+ }
4935
+ case "snapshot": {
4936
+ const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
4937
+ let newest;
4938
+ try {
4939
+ const store3 = await getSnapshotStore();
4940
+ for (const d of devices) {
4941
+ const at = store3.latest(d)?.ts;
4942
+ if (at !== undefined && (newest === undefined || at > newest))
4943
+ newest = at;
4944
+ }
4945
+ } catch (e) {
4946
+ logger.debug(`[alerts] snapshot lookup failed: ${e instanceof Error ? e.message : e}`);
4947
+ return Date.now();
4948
+ }
4949
+ return newest;
4950
+ }
4951
+ case "device_seen": {
4952
+ const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
4953
+ let newest;
4954
+ for (const d of devices) {
4955
+ const st = getDeviceStatus(d);
4956
+ if (st.reachable !== true || st.checkedAt == null)
4957
+ continue;
4958
+ if (newest === undefined || st.checkedAt > newest)
4959
+ newest = st.checkedAt;
4960
+ }
4961
+ return newest;
4962
+ }
4963
+ }
4964
+ }
4965
+ async function sampleAlertsOnce(now = Date.now()) {
4966
+ const engine = getAlertEngine();
4967
+ if (!engine)
4968
+ return;
4969
+ if (engine.hasMetricRules()) {
4970
+ engine.sample((windowMs2) => computeMetricSample(windowMs2, now));
4971
+ }
4972
+ if (engine.hasAbsenceRules()) {
4973
+ await engine.sampleAbsence(lastSeen, now);
4974
+ }
4975
+ }
4976
+ function startAlertSampler(intervalMs = DEFAULT_ALERT_SAMPLE_MS) {
4977
+ stopAlertSampler();
4978
+ timer4 = setInterval(() => {
4979
+ sampleAlertsOnce().catch((e) => {
4980
+ logger.debug(`[alerts] sampler tick failed: ${e instanceof Error ? e.message : String(e)}`);
4981
+ });
4982
+ }, intervalMs);
4983
+ timer4.unref?.();
4984
+ logger.debug(`[alerts] metric sampler started (${intervalMs}ms)`);
4985
+ }
4986
+ function stopAlertSampler() {
4987
+ if (timer4)
4988
+ clearInterval(timer4);
4989
+ timer4 = null;
4990
+ }
4991
+
3733
4992
  // src/cli.ts
3734
4993
  var HELP = `${SERVER_NAME} v${VERSION} \u2014 MikroTik RouterOS MCP server
3735
4994
 
@@ -3882,6 +5141,29 @@ async function main() {
3882
5141
  warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
3883
5142
  const deviceNames = Object.keys(cfg.devices);
3884
5143
  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"})`);
5144
+ if (cfg.alerts?.enabled !== false && cfg.alerts) {
5145
+ try {
5146
+ const rules = parseRules(cfg.alerts.rules);
5147
+ setAlertEngine(new AlertEngine({
5148
+ rules,
5149
+ channels: cfg.alerts.channels,
5150
+ historyDb: cfg.dashboard.dbPath,
5151
+ maxHistory: cfg.alerts.maxHistory
5152
+ }));
5153
+ startAlertSampler();
5154
+ logger.info(`Alerting enabled: ${rules.length} rule(s)`);
5155
+ } catch (e) {
5156
+ logger.error(`Alerting disabled \u2014 invalid rules: ${e instanceof Error ? e.message : String(e)}`);
5157
+ }
5158
+ }
5159
+ startScheduler().catch((e) => {
5160
+ logger.error(`Scheduled audits disabled: ${e instanceof Error ? e.message : String(e)}`);
5161
+ });
5162
+ try {
5163
+ startAttackDetection();
5164
+ } catch (e) {
5165
+ logger.error(`Attack detection disabled: ${e instanceof Error ? e.message : String(e)}`);
5166
+ }
3885
5167
  let shuttingDown = false;
3886
5168
  const shutdown = (signal) => {
3887
5169
  if (shuttingDown)
@@ -3890,6 +5172,9 @@ async function main() {
3890
5172
  logger.info(`Received ${signal}, shutting down\u2026`);
3891
5173
  closeAll();
3892
5174
  closeMemoryStore();
5175
+ stopAlertSampler();
5176
+ stopScheduler();
5177
+ stopAttackDetection();
3893
5178
  process.exit(0);
3894
5179
  };
3895
5180
  process.on("SIGINT", () => shutdown("SIGINT"));