@usex/mikrotik-mcp 4.23.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 (81) hide show
  1. package/README.md +350 -284
  2. package/dist/cli.js +1870 -566
  3. package/dist/index.d.ts +57 -0
  4. package/dist/index.js +1 -1
  5. package/dist/shared/{library-2144zr9f.js → cli-2dj736ma.js} +33920 -22075
  6. package/dist/shared/{cli-p673815b.js → cli-vvy7yfyg.js} +1 -1
  7. package/dist/shared/{cli-tssmr7sb.js → library-13dhfdnq.js} +29455 -18578
  8. package/dist/shared/{library-8f4pbp0x.js → library-syt5fmtj.js} +1 -1
  9. package/dist/ui/aaa.html +1 -1
  10. package/dist/ui/connected-devices.html +1 -1
  11. package/dist/ui/dashboard.html +1 -1
  12. package/dist/ui/firewall-audit.html +1 -1
  13. package/dist/ui/firewall.html +1 -1
  14. package/dist/ui/interfaces.html +1 -1
  15. package/dist/ui/observability.html +70 -62
  16. package/dist/ui/records.html +1 -1
  17. package/package.json +12 -11
  18. package/policies/baseline.yaml +180 -0
  19. package/prompts/backup-and-document.md +28 -18
  20. package/prompts/build-tunnel-transactionally.md +74 -0
  21. package/prompts/fleet-rollout.md +68 -0
  22. package/prompts/setup-traffic-flow.md +68 -0
  23. package/schemas/README.md +1 -1
  24. package/schemas/config.schema.json +367 -4
  25. package/schemas/tool-catalog.json +3044 -1152
  26. package/schemas/tools/abort_rollout.json +17 -0
  27. package/schemas/tools/abort_transaction.json +17 -0
  28. package/schemas/tools/add_alert_rule.json +45 -0
  29. package/schemas/tools/add_ipv6_route.json +1 -1
  30. package/schemas/tools/add_route.json +3 -2
  31. package/schemas/tools/add_schedule.json +50 -0
  32. package/schemas/tools/add_script.json +1 -1
  33. package/schemas/tools/add_traffic_flow_target.json +36 -0
  34. package/schemas/tools/add_transaction_step.json +21 -0
  35. package/schemas/tools/analyze_flows.json +25 -0
  36. package/schemas/tools/begin_transaction.json +109 -0
  37. package/schemas/tools/block_attacker.json +22 -0
  38. package/schemas/tools/check_policy_snapshot.json +20 -0
  39. package/schemas/tools/commit_transaction.json +13 -0
  40. package/schemas/tools/configure_attack_response.json +12 -0
  41. package/schemas/tools/diff_explanations.json +24 -0
  42. package/schemas/tools/explain_device.json +22 -0
  43. package/schemas/tools/explain_policy_finding.json +17 -0
  44. package/schemas/tools/explain_rule_reachability.json +18 -0
  45. package/schemas/tools/explain_section.json +32 -0
  46. package/schemas/tools/export_policy_report.json +22 -0
  47. package/schemas/tools/flow_top_talkers.json +31 -0
  48. package/schemas/tools/get_alert_history.json +26 -0
  49. package/schemas/tools/get_attack_incident.json +13 -0
  50. package/schemas/tools/get_audit_timeline.json +24 -0
  51. package/schemas/tools/get_device_capabilities.json +7 -0
  52. package/schemas/tools/get_traffic_flow_settings.json +7 -0
  53. package/schemas/tools/list_alert_rules.json +7 -0
  54. package/schemas/tools/list_attack_incidents.json +27 -0
  55. package/schemas/tools/list_attack_responses.json +20 -0
  56. package/schemas/tools/list_policies.json +17 -0
  57. package/schemas/tools/list_schedules.json +12 -0
  58. package/schemas/tools/list_traffic_flow_targets.json +7 -0
  59. package/schemas/tools/mute_alert_rule.json +18 -0
  60. package/schemas/tools/plan_rollout.json +95 -0
  61. package/schemas/tools/refresh_device_capabilities.json +7 -0
  62. package/schemas/tools/remove_alert_rule.json +13 -0
  63. package/schemas/tools/remove_schedule.json +13 -0
  64. package/schemas/tools/remove_traffic_flow_target.json +13 -0
  65. package/schemas/tools/rollout_status.json +12 -0
  66. package/schemas/tools/run_policy_check.json +12 -0
  67. package/schemas/tools/run_schedule_now.json +13 -0
  68. package/schemas/tools/scan_for_attacks.json +21 -0
  69. package/schemas/tools/set_traffic_flow_settings.json +28 -0
  70. package/schemas/tools/simulate_change.json +72 -0
  71. package/schemas/tools/simulate_packet.json +53 -0
  72. package/schemas/tools/simulate_suite.json +71 -0
  73. package/schemas/tools/start_flow_collector.json +14 -0
  74. package/schemas/tools/start_rollout.json +98 -0
  75. package/schemas/tools/stop_flow_collector.json +7 -0
  76. package/schemas/tools/test_alert_channel.json +14 -0
  77. package/schemas/tools/unblock_attacker.json +20 -0
  78. package/schemas/tools/update_alert_rule.json +41 -0
  79. package/schemas/tools/update_route.json +3 -2
  80. package/schemas/tools/validate_policy_file.json +16 -0
  81. 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-tssmr7sb.js";
233
+ } from "./shared/cli-2dj736ma.js";
129
234
 
130
235
  // src/cli.ts
131
236
  import { existsSync as existsSync2 } from "fs";
@@ -133,12 +238,24 @@ import { existsSync as existsSync2 } from "fs";
133
238
  // src/observability/dashboard.ts
134
239
  import { spawn } from "child_process";
135
240
  import { readFileSync as readFileSync3 } from "fs";
136
- import { homedir as homedir2, networkInterfaces } from "os";
137
- import { dirname as dirname5, join as join4 } from "path";
241
+ import { homedir as homedir3, networkInterfaces } from "os";
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
  }
@@ -1676,6 +1526,27 @@ function computeStats(events, opts) {
1676
1526
  };
1677
1527
  }
1678
1528
 
1529
+ // src/observability/http-error.ts
1530
+ import { homedir as homedir2 } from "os";
1531
+ var HOME = homedir2();
1532
+ var MAX_LEN = 300;
1533
+ function sanitizeMessage(raw, home, fallback) {
1534
+ const line = raw.split(`
1535
+ `, 1)[0].trim();
1536
+ const masked = home.length > 1 ? line.split(home).join("~") : line;
1537
+ if (!masked)
1538
+ return fallback;
1539
+ return masked.length > MAX_LEN ? `${masked.slice(0, MAX_LEN)}\u2026` : masked;
1540
+ }
1541
+ function clientError(e, fallback = "unknown error") {
1542
+ return sanitizeMessage(e instanceof Error ? e.message : String(e), HOME, fallback);
1543
+ }
1544
+ function logError(e) {
1545
+ if (e instanceof Error)
1546
+ return e.stack ?? e.message;
1547
+ return String(e);
1548
+ }
1549
+
1679
1550
  // src/observability/drift-routes.ts
1680
1551
  var storePromise = null;
1681
1552
  function getStore() {
@@ -1801,6 +1672,7 @@ async function driftRoutes(req, url) {
1801
1672
  toLabel: `${device}@live`
1802
1673
  });
1803
1674
  const report = analyzeDrift(diff, device, baseline.snapshotId, baseline.setAt);
1675
+ noteDriftResult(report);
1804
1676
  try {
1805
1677
  const logCmd = `/log print where topics~"system" and time > ([:timestamp] - 7d)`;
1806
1678
  const logText = await executeMikrotikCommand(logCmd, ctx);
@@ -1810,18 +1682,1267 @@ async function driftRoutes(req, url) {
1810
1682
  } catch {}
1811
1683
  return json(report);
1812
1684
  } catch (e) {
1813
- return json({ error: e instanceof Error ? e.message : String(e) }, 502);
1685
+ return json({ error: clientError(e) }, 502);
1814
1686
  }
1815
1687
  }
1816
1688
  return null;
1817
1689
  }
1818
1690
 
1819
- // src/observability/memory-routes.ts
1691
+ // src/observability/txn-routes.ts
1820
1692
  var JSON_HEADERS2 = { "content-type": "application/json; charset=utf-8" };
1821
1693
  function json2(body, status = 200) {
1822
1694
  return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS2 });
1823
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
+ }
1824
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) {
1825
2946
  return await req.json();
1826
2947
  }
1827
2948
  async function memoryRoutes(req, url) {
@@ -1830,29 +2951,29 @@ async function memoryRoutes(req, url) {
1830
2951
  return null;
1831
2952
  const cfg = getConfig();
1832
2953
  if (!cfg.memory.enabled) {
1833
- return json2({ error: "Knowledge-graph memory is disabled" }, 503);
2954
+ return json11({ error: "Knowledge-graph memory is disabled" }, 503);
1834
2955
  }
1835
2956
  if (p === "/api/memory/config" && req.method === "GET") {
1836
2957
  let stats = null;
1837
2958
  try {
1838
- const store2 = await getMemoryStore();
1839
- stats = store2.stats();
2959
+ const store4 = await getMemoryStore();
2960
+ stats = store4.stats();
1840
2961
  } catch {}
1841
- return json2({
2962
+ return json11({
1842
2963
  enabled: cfg.memory.enabled,
1843
2964
  dbPath: cfg.memory.dbPath,
1844
2965
  stats
1845
2966
  });
1846
2967
  }
1847
2968
  if (p === "/api/memory/config" && req.method === "POST") {
1848
- const body = await bodyJson2(req);
2969
+ const body = await bodyJson4(req);
1849
2970
  const updates = {};
1850
2971
  if (body.dbPath !== undefined && typeof body.dbPath === "string" && body.dbPath !== cfg.memory.dbPath) {
1851
2972
  updates.dbPath = body.dbPath;
1852
2973
  try {
1853
2974
  await reopenMemoryStore(body.dbPath);
1854
2975
  } catch (e) {
1855
- return json2({
2976
+ return json11({
1856
2977
  error: `Failed to open memory DB at ${body.dbPath}: ${e instanceof Error ? e.message : String(e)}`
1857
2978
  }, 400);
1858
2979
  }
@@ -1871,79 +2992,79 @@ async function memoryRoutes(req, url) {
1871
2992
  }
1872
2993
  let stats = null;
1873
2994
  try {
1874
- const store2 = await getMemoryStore();
1875
- stats = store2.stats();
2995
+ const store4 = await getMemoryStore();
2996
+ stats = store4.stats();
1876
2997
  } catch {}
1877
- return json2({
2998
+ return json11({
1878
2999
  ok: true,
1879
3000
  enabled: getConfig().memory.enabled,
1880
3001
  dbPath: getConfig().memory.dbPath,
1881
3002
  stats
1882
3003
  });
1883
3004
  }
1884
- const store = await getMemoryStore();
3005
+ const store3 = await getMemoryStore();
1885
3006
  if (p === "/api/memory/graph" && req.method === "GET") {
1886
- return json2(store.readGraph());
3007
+ return json11(store3.readGraph());
1887
3008
  }
1888
3009
  if (p === "/api/memory/stats" && req.method === "GET") {
1889
- return json2(store.stats());
3010
+ return json11(store3.stats());
1890
3011
  }
1891
3012
  if (p === "/api/memory/search" && req.method === "GET") {
1892
3013
  const q = url.searchParams.get("q") || "";
1893
3014
  const limit = Number(url.searchParams.get("limit") ?? 50);
1894
- return json2(store.searchNodes(q, limit));
3015
+ return json11(store3.searchNodes(q, limit));
1895
3016
  }
1896
3017
  if (p === "/api/memory/activity" && req.method === "GET") {
1897
3018
  const limit = Number(url.searchParams.get("limit") ?? 50);
1898
3019
  const since = url.searchParams.get("since");
1899
- return json2(store.activity(limit, since ? Number(since) : undefined));
3020
+ return json11(store3.activity(limit, since ? Number(since) : undefined));
1900
3021
  }
1901
3022
  const entityMatch = p.match(/^\/api\/memory\/entity\/(.+)$/);
1902
3023
  if (entityMatch && req.method === "GET") {
1903
3024
  const name = decodeURIComponent(entityMatch[1]);
1904
- const graph = store.openNodes([name]);
3025
+ const graph = store3.openNodes([name]);
1905
3026
  if (graph.entities.length === 0)
1906
- return json2({ error: "Entity not found" }, 404);
1907
- 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 });
1908
3029
  }
1909
3030
  if (p === "/api/memory/entities" && req.method === "POST") {
1910
- const body = await bodyJson2(req);
1911
- const created = store.createEntities(body.entities ?? []);
1912
- 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 });
1913
3034
  }
1914
3035
  if (p === "/api/memory/relations" && req.method === "POST") {
1915
- const body = await bodyJson2(req);
1916
- const created = store.createRelations(body.relations ?? []);
1917
- 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 });
1918
3039
  }
1919
3040
  if (p === "/api/memory/observations" && req.method === "POST") {
1920
- const body = await bodyJson2(req);
1921
- const results = store.addObservations(body.observations ?? []);
1922
- return json2({ results });
3041
+ const body = await bodyJson4(req);
3042
+ const results = store3.addObservations(body.observations ?? []);
3043
+ return json11({ results });
1923
3044
  }
1924
3045
  if (p === "/api/memory/entities" && req.method === "DELETE") {
1925
- const body = await bodyJson2(req);
1926
- const removed = store.deleteEntities(body.names ?? []);
1927
- return json2({ removed });
3046
+ const body = await bodyJson4(req);
3047
+ const removed = store3.deleteEntities(body.names ?? []);
3048
+ return json11({ removed });
1928
3049
  }
1929
3050
  if (p === "/api/memory/relations" && req.method === "DELETE") {
1930
- const body = await bodyJson2(req);
1931
- const removed = store.deleteRelations(body.relations ?? []);
1932
- return json2({ removed });
3051
+ const body = await bodyJson4(req);
3052
+ const removed = store3.deleteRelations(body.relations ?? []);
3053
+ return json11({ removed });
1933
3054
  }
1934
3055
  if (p === "/api/memory/observations" && req.method === "DELETE") {
1935
- const body = await bodyJson2(req);
1936
- const removed = store.deleteObservations(body.deletions ?? []);
1937
- return json2({ removed });
3056
+ const body = await bodyJson4(req);
3057
+ const removed = store3.deleteObservations(body.deletions ?? []);
3058
+ return json11({ removed });
1938
3059
  }
1939
3060
  return null;
1940
3061
  }
1941
3062
 
1942
3063
  // src/observability/dashboard.ts
1943
3064
  var SERVER_TAG3 = "mikrotik-mcp";
1944
- var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
1945
- function json3(body, status = 200) {
1946
- 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 });
1947
3068
  }
1948
3069
  function restartProcess() {
1949
3070
  let ok = false;
@@ -1958,7 +3079,7 @@ function restartProcess() {
1958
3079
  ok = true;
1959
3080
  logger.warn("Dashboard requested a restart \u2014 relaunched a fresh server process; this one is exiting.");
1960
3081
  } catch (e) {
1961
- logger.error(`Restart relaunch failed: ${e instanceof Error ? e.message : String(e)}. Exiting anyway.`);
3082
+ logger.error(`Restart relaunch failed: ${logError(e)}. Exiting anyway.`);
1962
3083
  }
1963
3084
  setTimeout(() => process.exit(0), 500);
1964
3085
  return ok;
@@ -1971,7 +3092,7 @@ function runUpgrade(spec) {
1971
3092
  env: process.env,
1972
3093
  stdio: ["ignore", "pipe", "pipe"]
1973
3094
  });
1974
- const timer5 = setTimeout(() => {
3095
+ const timer4 = setTimeout(() => {
1975
3096
  child.kill();
1976
3097
  resolve({ ok: false, log: `${out}
1977
3098
  (timed out after 180s)` });
@@ -1979,12 +3100,12 @@ function runUpgrade(spec) {
1979
3100
  child.stdout?.on("data", (d) => out += d.toString());
1980
3101
  child.stderr?.on("data", (d) => out += d.toString());
1981
3102
  child.on("error", (e) => {
1982
- clearTimeout(timer5);
3103
+ clearTimeout(timer4);
1983
3104
  resolve({ ok: false, log: `${out}
1984
3105
  spawn error: ${String(e)}` });
1985
3106
  });
1986
3107
  child.on("exit", (code) => {
1987
- clearTimeout(timer5);
3108
+ clearTimeout(timer4);
1988
3109
  resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
1989
3110
  });
1990
3111
  });
@@ -2018,8 +3139,8 @@ function filterFromQuery(url) {
2018
3139
  until: num("until")
2019
3140
  };
2020
3141
  }
2021
- function facets(store) {
2022
- const recent = store.query({ limit: 5000 });
3142
+ function facets(store3) {
3143
+ const recent = store3.query({ limit: 5000 });
2023
3144
  const tools = new Set;
2024
3145
  const devices = new Set;
2025
3146
  for (const e of recent) {
@@ -2032,8 +3153,8 @@ function facets(store) {
2032
3153
  devices: [...devices].sort()
2033
3154
  };
2034
3155
  }
2035
- function deviceActivity(store) {
2036
- const recent = store.query({ limit: 5000 });
3156
+ function deviceActivity(store3) {
3157
+ const recent = store3.query({ limit: 5000 });
2037
3158
  const map = new Map;
2038
3159
  for (const e of recent) {
2039
3160
  if (!e.device)
@@ -2062,9 +3183,9 @@ function deviceActivity(store) {
2062
3183
  }
2063
3184
  return out;
2064
3185
  }
2065
- function devicesPayload(store) {
3186
+ function devicesPayload(store3) {
2066
3187
  const cfg = getConfig();
2067
- const activity = deviceActivity(store);
3188
+ const activity = deviceActivity(store3);
2068
3189
  const poolEnabled = isPoolEnabled();
2069
3190
  const poolMap = new Map(poolStatus().map((p) => [p.device, p]));
2070
3191
  const devices = Object.entries(cfg.devices).map(([name, dc]) => {
@@ -2075,7 +3196,8 @@ function devicesPayload(store) {
2075
3196
  host: dc.host,
2076
3197
  port: dc.port,
2077
3198
  mac: dc.mac,
2078
- transport: dc.mac ? "mac-telnet" : "ssh",
3199
+ transport: dc.mac ? "mac-telnet" : dc.api ? "rest" : "ssh",
3200
+ restPort: dc.api ? dc.apiPort ?? 443 : undefined,
2079
3201
  address: dc.mac ? dc.mac : `${dc.host}:${dc.port}`,
2080
3202
  username: dc.username,
2081
3203
  authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
@@ -2237,11 +3359,11 @@ function issues(error) {
2237
3359
  async function configRoutes(req, url, admin) {
2238
3360
  const p = url.pathname;
2239
3361
  if (p === "/api/config-schema" && req.method === "GET") {
2240
- return json3(configSchemaJson());
3362
+ return json12(configSchemaJson());
2241
3363
  }
2242
3364
  if (p === "/api/config/validate" && req.method === "POST") {
2243
3365
  const merged = mergeSecrets(await readJson(req), getConfig());
2244
- return json3(validateConfig(merged));
3366
+ return json12(validateConfig(merged));
2245
3367
  }
2246
3368
  if (p === "/api/config/test-device" && req.method === "POST") {
2247
3369
  const body = await readJson(req);
@@ -2250,22 +3372,22 @@ async function configRoutes(req, url, admin) {
2250
3372
  const merged = mergeSecrets(body?.config, currentDc);
2251
3373
  const parsed = DeviceConfigSchema.safeParse(merged);
2252
3374
  if (!parsed.success)
2253
- return json3({ ok: false, errors: issues(parsed.error) }, 400);
3375
+ return json12({ ok: false, errors: issues(parsed.error) }, 400);
2254
3376
  const status = await probeDevice(`config-test:${name}`, parsed.data);
2255
- return json3({ ok: true, status });
3377
+ return json12({ ok: true, status });
2256
3378
  }
2257
3379
  if (p === "/api/config/preview" && req.method === "POST") {
2258
3380
  const before = JSON.stringify(redact(getConfig()), null, 2);
2259
3381
  const after = JSON.stringify(await readJson(req) ?? {}, null, 2);
2260
3382
  const d = diffLines(before, after, { fromLabel: "current", toLabel: "edited" });
2261
- return json3({ summary: d.summary, unified: d.unified });
3383
+ return json12({ summary: d.summary, unified: d.unified });
2262
3384
  }
2263
3385
  if (p === "/api/config" && req.method === "POST") {
2264
3386
  const body = await readJson(req);
2265
3387
  const merged = mergeSecrets(body?.config, getConfig());
2266
3388
  const v = validateConfig(merged);
2267
3389
  if (!v.ok || !v.value)
2268
- return json3({ ok: false, errors: v.errors }, 400);
3390
+ return json12({ ok: false, errors: v.errors }, 400);
2269
3391
  const prev = getConfig();
2270
3392
  const before = JSON.stringify(redact(prev), null, 2);
2271
3393
  const after = JSON.stringify(redact(v.value), null, 2);
@@ -2274,25 +3396,25 @@ async function configRoutes(req, url, admin) {
2274
3396
  const nextDevs = Object.keys(v.value.devices);
2275
3397
  const devicesChanged = prevDevs.length !== nextDevs.length || prevDevs.some((d) => !nextDevs.includes(d));
2276
3398
  const res = admin.applyConfig(v.value, clampRollback(body?.rollbackMs));
2277
- 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 });
2278
3400
  }
2279
3401
  if (p === "/api/config/keep" && req.method === "POST") {
2280
3402
  const body = await readJson(req);
2281
3403
  const kept = admin.keepConfig(String(body?.pendingId ?? ""));
2282
3404
  if (kept)
2283
3405
  recordVersion(getConfig(), "auto", Date.now());
2284
- return json3({ kept });
3406
+ return json12({ kept });
2285
3407
  }
2286
3408
  if (p === "/api/config/rollback" && req.method === "POST") {
2287
3409
  const body = await readJson(req);
2288
3410
  const rolledBack = admin.rollback(String(body?.pendingId ?? ""));
2289
- return json3({ rolledBack, config: redact(getConfig()) });
3411
+ return json12({ rolledBack, config: redact(getConfig()) });
2290
3412
  }
2291
3413
  if (p === "/api/reload" && req.method === "POST") {
2292
3414
  const body = await readJson(req);
2293
3415
  if (body?.hard === true) {
2294
3416
  const relaunched = restartProcess();
2295
- return json3({
3417
+ return json12({
2296
3418
  ok: true,
2297
3419
  mode: "restart",
2298
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."
@@ -2303,11 +3425,10 @@ async function configRoutes(req, url, admin) {
2303
3425
  setConfig(cfg);
2304
3426
  const devices = Object.keys(cfg.devices);
2305
3427
  logger.info(`Config reloaded from source \u2014 ${devices.length} device(s): ${devices.join(", ")}`);
2306
- return json3({ ok: true, mode: "config", count: devices.length, devices });
3428
+ return json12({ ok: true, mode: "config", count: devices.length, devices });
2307
3429
  } catch (e) {
2308
- const error = e instanceof Error ? e.message : String(e);
2309
- logger.error(`Config reload failed: ${error}`);
2310
- return json3({ ok: false, error }, 500);
3430
+ logger.error(`Config reload failed: ${logError(e)}`);
3431
+ return json12({ ok: false, error: clientError(e) }, 500);
2311
3432
  }
2312
3433
  }
2313
3434
  if (p === "/api/config/history" && req.method === "GET") {
@@ -2323,55 +3444,55 @@ async function configRoutes(req, url, admin) {
2323
3444
  } catch {}
2324
3445
  return { ...v, drift: { added, removed } };
2325
3446
  });
2326
- return json3({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
3447
+ return json12({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
2327
3448
  }
2328
3449
  if (p === "/api/config/history/get" && req.method === "GET") {
2329
3450
  const id = url.searchParams.get("id");
2330
3451
  if (!id)
2331
- return json3({ error: "id required" }, 400);
3452
+ return json12({ error: "id required" }, 400);
2332
3453
  try {
2333
3454
  const v = readVersion(id);
2334
- return json3({
3455
+ return json12({
2335
3456
  ts: v.ts,
2336
3457
  kind: v.kind,
2337
3458
  label: v.label,
2338
3459
  config: JSON.stringify(redact(v.config), null, 2)
2339
3460
  });
2340
3461
  } catch {
2341
- return json3({ error: "not found" }, 404);
3462
+ return json12({ error: "not found" }, 404);
2342
3463
  }
2343
3464
  }
2344
3465
  if (p === "/api/config/history/diff" && req.method === "GET") {
2345
3466
  const id = url.searchParams.get("id");
2346
3467
  if (!id)
2347
- return json3({ error: "id required" }, 400);
3468
+ return json12({ error: "id required" }, 400);
2348
3469
  try {
2349
3470
  const before = JSON.stringify(redact(readVersion(id).config), null, 2);
2350
3471
  const after = JSON.stringify(redact(getConfig()), null, 2);
2351
3472
  const d = diffLines(before, after, { fromLabel: "this version", toLabel: "current" });
2352
- return json3({ summary: d.summary, unified: d.unified });
3473
+ return json12({ summary: d.summary, unified: d.unified });
2353
3474
  } catch {
2354
- return json3({ error: "not found" }, 404);
3475
+ return json12({ error: "not found" }, 404);
2355
3476
  }
2356
3477
  }
2357
3478
  if (p === "/api/config/history/checkpoint" && req.method === "POST") {
2358
3479
  const b = await readJson(req);
2359
3480
  const label = b?.label?.trim() || "checkpoint";
2360
- return json3({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
3481
+ return json12({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
2361
3482
  }
2362
3483
  if (p === "/api/config/history/restore" && req.method === "POST") {
2363
3484
  const b = await readJson(req);
2364
3485
  if (!b?.id)
2365
- return json3({ error: "id required" }, 400);
3486
+ return json12({ error: "id required" }, 400);
2366
3487
  let target;
2367
3488
  try {
2368
3489
  target = readVersion(b.id);
2369
3490
  } catch {
2370
- return json3({ error: "not found" }, 404);
3491
+ return json12({ error: "not found" }, 404);
2371
3492
  }
2372
3493
  const v = validateConfig(target.config);
2373
3494
  if (!v.ok || !v.value)
2374
- return json3({ ok: false, errors: v.errors }, 400);
3495
+ return json12({ ok: false, errors: v.errors }, 400);
2375
3496
  recordVersion(getConfig(), "auto", Date.now(), "before restore");
2376
3497
  setConfig(v.value);
2377
3498
  let persisted = true;
@@ -2382,13 +3503,13 @@ async function configRoutes(req, url, admin) {
2382
3503
  }
2383
3504
  const label = target.label ? `restored "${target.label}"` : `restored ${b.id}`;
2384
3505
  recordVersion(getConfig(), "auto", Date.now(), label);
2385
- return json3({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
3506
+ return json12({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
2386
3507
  }
2387
3508
  if (p === "/api/config/history/delete" && req.method === "POST") {
2388
3509
  const b = await readJson(req);
2389
3510
  if (!b?.id)
2390
- return json3({ error: "id required" }, 400);
2391
- 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);
2392
3513
  }
2393
3514
  return null;
2394
3515
  }
@@ -2399,7 +3520,7 @@ async function modulesRoutes(req, url) {
2399
3520
  if (p === "/api/modules" && req.method === "GET") {
2400
3521
  const cfg = getConfig();
2401
3522
  const src = getConfigSource();
2402
- return json3({
3523
+ return json12({
2403
3524
  ...moduleSurface(cfg.tools),
2404
3525
  filter: cfg.tools,
2405
3526
  source: src,
@@ -2409,11 +3530,11 @@ async function modulesRoutes(req, url) {
2409
3530
  if (p === "/api/modules/toggle" && req.method === "POST") {
2410
3531
  const b = await readJson(req);
2411
3532
  if (typeof b?.slug !== "string" || typeof b?.enabled !== "boolean") {
2412
- return json3({ error: "slug (string) and enabled (boolean) are required" }, 400);
3533
+ return json12({ error: "slug (string) and enabled (boolean) are required" }, 400);
2413
3534
  }
2414
3535
  const mod = moduleCatalog.find((m) => m.slug.toLowerCase() === b.slug.toLowerCase());
2415
3536
  if (!mod)
2416
- return json3({ error: `unknown module: ${b.slug}` }, 404);
3537
+ return json12({ error: `unknown module: ${b.slug}` }, 404);
2417
3538
  const cfg = getConfig();
2418
3539
  const nextTools = ToolFilterSchema.parse(applyModuleToggle(cfg.tools, mod.slug, b.enabled));
2419
3540
  const next = { ...cfg, tools: nextTools };
@@ -2424,12 +3545,12 @@ async function modulesRoutes(req, url) {
2424
3545
  atomicWrite(getConfigSource().path, serializeConfig(next));
2425
3546
  } catch (e) {
2426
3547
  persisted = false;
2427
- warning = `applied live but not saved to disk: ${e instanceof Error ? e.message : String(e)}`;
3548
+ warning = `applied live but not saved to disk: ${clientError(e)}`;
2428
3549
  }
2429
3550
  if (persisted) {
2430
3551
  recordVersion(getConfig(), "auto", Date.now(), `module ${b.enabled ? "enabled" : "disabled"}: ${mod.slug}`);
2431
3552
  }
2432
- return json3({
3553
+ return json12({
2433
3554
  ok: true,
2434
3555
  persisted,
2435
3556
  requiresReconnect: true,
@@ -2444,7 +3565,7 @@ async function modulesRoutes(req, url) {
2444
3565
  if (p === "/api/modules/app-views" && req.method === "POST") {
2445
3566
  const b = await readJson(req);
2446
3567
  if (typeof b?.enabled !== "boolean") {
2447
- return json3({ error: "enabled (boolean) is required" }, 400);
3568
+ return json12({ error: "enabled (boolean) is required" }, 400);
2448
3569
  }
2449
3570
  const cfg = getConfig();
2450
3571
  const next = { ...cfg, mcp: { ...cfg.mcp, appViews: b.enabled } };
@@ -2455,12 +3576,12 @@ async function modulesRoutes(req, url) {
2455
3576
  atomicWrite(getConfigSource().path, serializeConfig(next));
2456
3577
  } catch (e) {
2457
3578
  persisted = false;
2458
- warning = `applied live but not saved to disk: ${e instanceof Error ? e.message : String(e)}`;
3579
+ warning = `applied live but not saved to disk: ${clientError(e)}`;
2459
3580
  }
2460
3581
  if (persisted) {
2461
3582
  recordVersion(getConfig(), "auto", Date.now(), `app views ${b.enabled ? "enabled" : "disabled"}`);
2462
3583
  }
2463
- return json3({
3584
+ return json12({
2464
3585
  ok: true,
2465
3586
  persisted,
2466
3587
  requiresReconnect: true,
@@ -2474,11 +3595,11 @@ async function modulesRoutes(req, url) {
2474
3595
  async function captureRoutes(req, url) {
2475
3596
  const p = url.pathname;
2476
3597
  if (p === "/api/capture/status" && req.method === "GET") {
2477
- return json3(capture.stats());
3598
+ return json12(capture.stats());
2478
3599
  }
2479
3600
  if (p === "/api/capture/packets" && req.method === "GET") {
2480
3601
  const limit = Number(url.searchParams.get("limit") ?? 200) || 200;
2481
- return json3({ packets: capture.recent(limit), stats: capture.stats() });
3602
+ return json12({ packets: capture.recent(limit), stats: capture.stats() });
2482
3603
  }
2483
3604
  if (p === "/api/capture/pcap" && req.method === "GET") {
2484
3605
  return new Response(capture.pcap(), {
@@ -2490,11 +3611,11 @@ async function captureRoutes(req, url) {
2490
3611
  }
2491
3612
  if (p === "/api/capture/start" && req.method === "POST") {
2492
3613
  const body = await readJson(req);
2493
- 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));
2494
3615
  }
2495
3616
  if (p === "/api/capture/stop" && req.method === "POST") {
2496
3617
  capture.stop();
2497
- return json3({ ok: true, ...capture.stats() });
3618
+ return json12({ ok: true, ...capture.stats() });
2498
3619
  }
2499
3620
  return null;
2500
3621
  }
@@ -2506,24 +3627,24 @@ async function capsmanRoutes(req, url) {
2506
3627
  if (req.method === "GET") {
2507
3628
  const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
2508
3629
  if (p === "/api/capsman/overview") {
2509
- return json3(capsmanOverview(await fetchCapsmanState(ctx)));
3630
+ return json12(capsmanOverview(await fetchCapsmanState(ctx)));
2510
3631
  }
2511
3632
  if (p === "/api/capsman/clients") {
2512
3633
  const state = await fetchCapsmanState(ctx);
2513
3634
  const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
2514
3635
  const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
2515
- return json3({ clients: state.clients, weak });
3636
+ return json12({ clients: state.clients, weak });
2516
3637
  }
2517
3638
  if (p === "/api/capsman/audit") {
2518
- return json3(runCapsmanAudit(await fetchCapsmanState(ctx)));
3639
+ return json12(runCapsmanAudit(await fetchCapsmanState(ctx)));
2519
3640
  }
2520
3641
  if (p === "/api/capsman/trends") {
2521
3642
  if (!capsmanStore)
2522
- return json3({ error: "capsman store not active" }, 503);
3643
+ return json12({ error: "capsman store not active" }, 503);
2523
3644
  const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2524
3645
  const days = daysParam(url, 7, 30);
2525
3646
  const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
2526
- return json3({ series, days });
3647
+ return json12({ series, days });
2527
3648
  }
2528
3649
  return null;
2529
3650
  }
@@ -2534,45 +3655,45 @@ async function capsmanRoutes(req, url) {
2534
3655
  if (p === "/api/capsman/apply/steer") {
2535
3656
  const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
2536
3657
  if (!client)
2537
- return json3({ ok: false, error: "client not associated" }, 400);
3658
+ return json12({ ok: false, error: "client not associated" }, 400);
2538
3659
  if (steerAlreadyPresent(state, body.mac ?? "")) {
2539
- return json3({ ok: true, message: "already steered (no-op)" });
3660
+ return json12({ ok: true, message: "already steered (no-op)" });
2540
3661
  }
2541
3662
  const mode = body.mode === "soft" ? "soft" : "hard";
2542
3663
  const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
2543
3664
  if (!body.confirm)
2544
- return json3({ ok: true, preview: commands });
2545
- 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}`));
2546
3667
  }
2547
3668
  if (p === "/api/capsman/apply/load-balance") {
2548
3669
  const plan = loadBalancePlan(state);
2549
3670
  const commands = buildLoadBalanceCommands(state, plan);
2550
3671
  if (!body.confirm)
2551
- return json3({ ok: true, preview: commands, plan });
2552
- 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"));
2553
3674
  }
2554
3675
  if (p === "/api/capsman/apply/channel-plan") {
2555
3676
  if (state.path === "/caps-man") {
2556
- 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);
2557
3678
  }
2558
3679
  const commands = buildChannelPlanCommands(state);
2559
3680
  if (!body.confirm)
2560
- return json3({ ok: true, preview: commands });
2561
- 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"));
2562
3683
  }
2563
3684
  if (p === "/api/capsman/apply/ft") {
2564
3685
  const commands = buildFtCommands(state);
2565
3686
  if (!body.confirm)
2566
- return json3({ ok: true, preview: commands });
2567
- return json3(await applyCapsmanWrites(ctx, commands, "pre-ft"));
3687
+ return json12({ ok: true, preview: commands });
3688
+ return json12(await applyCapsmanWrites(ctx, commands, "pre-ft"));
2568
3689
  }
2569
3690
  if (p === "/api/capsman/apply/ha") {
2570
3691
  const commands = buildHaCommands(state);
2571
3692
  const guidance = haGuidance(state);
2572
3693
  if (!body.confirm)
2573
- return json3({ ok: true, preview: commands, guidance });
3694
+ return json12({ ok: true, preview: commands, guidance });
2574
3695
  const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
2575
- return json3({ ...res, guidance });
3696
+ return json12({ ...res, guidance });
2576
3697
  }
2577
3698
  }
2578
3699
  return null;
@@ -2598,32 +3719,32 @@ async function clientsRoutes(req, url) {
2598
3719
  const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
2599
3720
  if (p === "/api/clients" && req.method === "GET") {
2600
3721
  const ctx = createContext(undefined, deviceFromQuery());
2601
- return json3(devicesView(await fetchDevices(ctx)));
3722
+ return json12(devicesView(await fetchDevices(ctx)));
2602
3723
  }
2603
3724
  if (p === "/api/clients/traffic" && req.method === "GET") {
2604
3725
  const ip = url.searchParams.get("ip");
2605
3726
  if (!ip)
2606
- return json3({ error: "ip required" }, 400);
3727
+ return json12({ error: "ip required" }, 400);
2607
3728
  const ctx = createContext(undefined, deviceFromQuery());
2608
- return json3(await sampleDeviceTraffic(ctx, ip));
3729
+ return json12(await sampleDeviceTraffic(ctx, ip));
2609
3730
  }
2610
3731
  if (p === "/api/clients/traffic-bulk" && req.method === "GET") {
2611
3732
  const ctx = createContext(undefined, deviceFromQuery());
2612
- return json3(await sampleAllTraffic(ctx));
3733
+ return json12(await sampleAllTraffic(ctx));
2613
3734
  }
2614
3735
  if (req.method === "POST") {
2615
3736
  const b = await readJson(req);
2616
3737
  const ctx = createContext(undefined, b?.device);
2617
3738
  if (p === "/api/clients/limits") {
2618
3739
  if (!b?.ip)
2619
- return json3({ error: "ip required" }, 400);
2620
- 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 }));
2621
3742
  }
2622
3743
  if (!b?.mac)
2623
- return json3({ error: "mac required" }, 400);
3744
+ return json12({ error: "mac required" }, 400);
2624
3745
  const run = async (op) => {
2625
3746
  const r = await op;
2626
- return json3({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
3747
+ return json12({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
2627
3748
  };
2628
3749
  if (p === "/api/clients/block")
2629
3750
  return run(blockDevice(ctx, b.mac, b.comment));
@@ -2635,12 +3756,12 @@ async function clientsRoutes(req, url) {
2635
3756
  return run(removeDeviceLease(ctx, b.mac));
2636
3757
  if (p === "/api/clients/set-ip") {
2637
3758
  if (!b.ip)
2638
- return json3({ error: "ip required" }, 400);
3759
+ return json12({ error: "ip required" }, 400);
2639
3760
  return run(setDeviceIp(ctx, b.mac, b.ip));
2640
3761
  }
2641
3762
  if (p === "/api/clients/label") {
2642
3763
  if (typeof b.label !== "string")
2643
- return json3({ error: "label required" }, 400);
3764
+ return json12({ error: "label required" }, 400);
2644
3765
  return run(setDeviceLabel(ctx, b.mac, b.label));
2645
3766
  }
2646
3767
  }
@@ -2653,20 +3774,20 @@ async function aaaRoutes(req, url) {
2653
3774
  const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
2654
3775
  if (req.method === "GET") {
2655
3776
  if (p === "/api/aaa/entities") {
2656
- return json3({ entities: AAA_ENTITIES });
3777
+ return json12({ entities: AAA_ENTITIES });
2657
3778
  }
2658
3779
  if (p === "/api/aaa/radius-incoming") {
2659
- return json3(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
3780
+ return json12(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
2660
3781
  }
2661
3782
  if (p === "/api/aaa/um-settings") {
2662
- return json3(await getUmSettings(createContext(undefined, deviceFromQuery())));
3783
+ return json12(await getUmSettings(createContext(undefined, deviceFromQuery())));
2663
3784
  }
2664
3785
  const listMatch = p.match(/^\/api\/aaa\/list\/([\w-]+)$/);
2665
3786
  if (listMatch) {
2666
3787
  const slug = listMatch[1];
2667
3788
  if (!AAA_ENTITIES[slug])
2668
- return json3({ error: "unknown entity" }, 404);
2669
- return json3(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
3789
+ return json12({ error: "unknown entity" }, 404);
3790
+ return json12(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
2670
3791
  }
2671
3792
  return null;
2672
3793
  }
@@ -2674,30 +3795,30 @@ async function aaaRoutes(req, url) {
2674
3795
  const body = await readJson(req);
2675
3796
  const ctx = createContext(undefined, body?.device);
2676
3797
  if (p === "/api/aaa/radius-incoming")
2677
- return json3(await setRadiusIncoming(ctx, body?.fields ?? {}));
3798
+ return json12(await setRadiusIncoming(ctx, body?.fields ?? {}));
2678
3799
  if (p === "/api/aaa/um-settings")
2679
- return json3(await setUmSettings(ctx, body?.fields ?? {}));
3800
+ return json12(await setUmSettings(ctx, body?.fields ?? {}));
2680
3801
  if (p === "/api/aaa/radius-reset-counters")
2681
- return json3(await resetRadiusCounters(ctx));
3802
+ return json12(await resetRadiusCounters(ctx));
2682
3803
  const slug = body?.slug ?? "";
2683
3804
  if (!AAA_ENTITIES[slug])
2684
- return json3({ error: "unknown entity" }, 404);
3805
+ return json12({ error: "unknown entity" }, 404);
2685
3806
  if (p === "/api/aaa/add")
2686
- return json3(await addAaaEntity(ctx, slug, body?.fields ?? {}));
3807
+ return json12(await addAaaEntity(ctx, slug, body?.fields ?? {}));
2687
3808
  if (p === "/api/aaa/update") {
2688
3809
  if (!body?.id)
2689
- return json3({ error: "id required" }, 400);
2690
- 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 ?? {}));
2691
3812
  }
2692
3813
  if (p === "/api/aaa/remove") {
2693
3814
  if (!body?.id)
2694
- return json3({ error: "id required" }, 400);
2695
- return json3(await removeAaaEntity(ctx, slug, body.id));
3815
+ return json12({ error: "id required" }, 400);
3816
+ return json12(await removeAaaEntity(ctx, slug, body.id));
2696
3817
  }
2697
3818
  if (p === "/api/aaa/toggle") {
2698
3819
  if (!body?.id)
2699
- return json3({ error: "id required" }, 400);
2700
- 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));
2701
3822
  }
2702
3823
  }
2703
3824
  return null;
@@ -2713,43 +3834,43 @@ async function usageRoutes(req, url) {
2713
3834
  return null;
2714
3835
  if (p === "/api/usage/sampler") {
2715
3836
  if (req.method === "GET")
2716
- return json3({ intervalMs: getUsageSamplerInterval() });
3837
+ return json12({ intervalMs: getUsageSamplerInterval() });
2717
3838
  if (req.method === "POST") {
2718
3839
  const b = await readJson(req);
2719
3840
  const applied = setUsageSamplerInterval(Number(b?.intervalMs));
2720
- return json3({ intervalMs: applied });
3841
+ return json12({ intervalMs: applied });
2721
3842
  }
2722
3843
  return null;
2723
3844
  }
2724
3845
  if (req.method !== "GET")
2725
3846
  return null;
2726
3847
  if (!usageStore)
2727
- return json3({ error: "usage store not active" }, 503);
3848
+ return json12({ error: "usage store not active" }, 503);
2728
3849
  const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
2729
3850
  const sinceTs = (days) => Date.now() - days * 86400000;
2730
3851
  if (p === "/api/usage/um-users") {
2731
- return json3({ users: usageStore.umUsers(device) });
3852
+ return json12({ users: usageStore.umUsers(device) });
2732
3853
  }
2733
3854
  if (p === "/api/usage/client") {
2734
3855
  const ip = url.searchParams.get("ip");
2735
3856
  if (!ip)
2736
- return json3({ error: "ip required" }, 400);
3857
+ return json12({ error: "ip required" }, 400);
2737
3858
  const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
2738
- return json3(withTotals(series));
3859
+ return json12(withTotals(series));
2739
3860
  }
2740
3861
  if (p === "/api/usage/um-user") {
2741
3862
  const user = url.searchParams.get("user");
2742
3863
  if (!user)
2743
- return json3({ error: "user required" }, 400);
3864
+ return json12({ error: "user required" }, 400);
2744
3865
  const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
2745
- return json3(withTotals(series));
3866
+ return json12(withTotals(series));
2746
3867
  }
2747
3868
  if (p === "/api/usage/heatmap") {
2748
3869
  const user = url.searchParams.get("user");
2749
3870
  const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
2750
3871
  const total = days.reduce((s, d) => s + d.count, 0);
2751
3872
  const max = days.reduce((m, d) => Math.max(m, d.count), 0);
2752
- return json3({ days, total, max });
3873
+ return json12({ days, total, max });
2753
3874
  }
2754
3875
  return null;
2755
3876
  }
@@ -2769,31 +3890,31 @@ function snapStore() {
2769
3890
  async function featureRoutes(req, url) {
2770
3891
  const p = url.pathname;
2771
3892
  if (p === "/api/snapshots" && req.method === "GET") {
2772
- const store = await snapStore();
3893
+ const store3 = await snapStore();
2773
3894
  const cfg = getConfig();
2774
- 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));
2775
3896
  all.sort((a, b) => b.ts - a.ts);
2776
- return json3({ snapshots: all });
3897
+ return json12({ snapshots: all });
2777
3898
  }
2778
3899
  const snapMatch = p.match(/^\/api\/snapshot\/(.+)$/);
2779
3900
  if (snapMatch && req.method === "GET") {
2780
- const store = await snapStore();
2781
- const s = store.get(decodeURIComponent(snapMatch[1]));
2782
- 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);
2783
3904
  }
2784
3905
  if (p === "/api/snapshots/diff" && req.method === "POST") {
2785
- const store = await snapStore();
3906
+ const store3 = await snapStore();
2786
3907
  const b = await readJson(req);
2787
- const from = b?.from ? store.get(b.from) : null;
2788
- 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;
2789
3910
  if (!from || !to) {
2790
- 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);
2791
3912
  }
2792
3913
  const diff = diffLines(normalizeExport(from.body), normalizeExport(to.body), {
2793
3914
  fromLabel: from.label ?? from.id,
2794
3915
  toLabel: to.label ?? to.id
2795
3916
  });
2796
- return json3({
3917
+ return json12({
2797
3918
  from: { id: from.id, label: from.label, ts: from.ts, device: from.device },
2798
3919
  to: { id: to.id, label: to.label, ts: to.ts, device: to.device },
2799
3920
  summary: diff.summary,
@@ -2807,16 +3928,16 @@ async function featureRoutes(req, url) {
2807
3928
  ...b?.script ? splitCommands(b.script) : []
2808
3929
  ];
2809
3930
  if (commands.length === 0)
2810
- return json3({ error: "no commands provided" }, 400);
3931
+ return json12({ error: "no commands provided" }, 400);
2811
3932
  const plan = buildChangePlan(commands);
2812
- return json3({ plan, text: renderPlan(plan) });
3933
+ return json12({ plan, text: renderPlan(plan) });
2813
3934
  }
2814
3935
  if (p === "/api/s3" && req.method === "GET") {
2815
- return json3({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
3936
+ return json12({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
2816
3937
  }
2817
3938
  if (p === "/api/s3/list" && req.method === "GET") {
2818
3939
  if (!isS3Configured())
2819
- return json3({ configured: false, objects: [] });
3940
+ return json12({ configured: false, objects: [] });
2820
3941
  const prefix = url.searchParams.get("prefix") ?? "";
2821
3942
  try {
2822
3943
  const res = await getS3Client().list({ prefix: prefix || undefined, maxKeys: 1000 });
@@ -2825,42 +3946,42 @@ async function featureRoutes(req, url) {
2825
3946
  size: o.size ?? 0,
2826
3947
  lastModified: o.lastModified ?? null
2827
3948
  }));
2828
- return json3({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
3949
+ return json12({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
2829
3950
  } catch (e) {
2830
- return json3({ error: e instanceof Error ? e.message : String(e) }, 502);
3951
+ return json12({ error: clientError(e) }, 502);
2831
3952
  }
2832
3953
  }
2833
3954
  if (p === "/api/s3/presign" && req.method === "GET") {
2834
3955
  if (!isS3Configured())
2835
- return json3({ error: "S3 not configured" }, 400);
3956
+ return json12({ error: "S3 not configured" }, 400);
2836
3957
  const key = url.searchParams.get("key");
2837
3958
  if (!key)
2838
- return json3({ error: "key required" }, 400);
3959
+ return json12({ error: "key required" }, 400);
2839
3960
  try {
2840
3961
  const link = getS3Client().presign(key, { expiresIn: presignExpiresIn(), method: "GET" });
2841
- return json3({ url: link });
3962
+ return json12({ url: link });
2842
3963
  } catch (e) {
2843
- return json3({ error: e instanceof Error ? e.message : String(e) }, 502);
3964
+ return json12({ error: clientError(e) }, 502);
2844
3965
  }
2845
3966
  }
2846
3967
  if (p === "/api/s3/delete" && req.method === "POST") {
2847
3968
  if (!isS3Configured())
2848
- return json3({ error: "S3 not configured" }, 400);
3969
+ return json12({ error: "S3 not configured" }, 400);
2849
3970
  const b = await readJson(req);
2850
3971
  if (!b?.key)
2851
- return json3({ error: "key required" }, 400);
3972
+ return json12({ error: "key required" }, 400);
2852
3973
  try {
2853
3974
  const client = getS3Client();
2854
3975
  if (!await client.exists(b.key))
2855
- return json3({ error: "not found" }, 404);
3976
+ return json12({ error: "not found" }, 404);
2856
3977
  await client.delete(b.key);
2857
- return json3({ ok: true, key: b.key });
3978
+ return json12({ ok: true, key: b.key });
2858
3979
  } catch (e) {
2859
- return json3({ error: e instanceof Error ? e.message : String(e) }, 502);
3980
+ return json12({ error: clientError(e) }, 502);
2860
3981
  }
2861
3982
  }
2862
3983
  if (p === "/api/backups" && req.method === "GET") {
2863
- return json3({
3984
+ return json12({
2864
3985
  dir: backupDir(),
2865
3986
  devices: Object.keys(getConfig().devices),
2866
3987
  backups: listBackups()
@@ -2870,31 +3991,31 @@ async function featureRoutes(req, url) {
2870
3991
  const b = await readJson(req);
2871
3992
  const raw = b?.dir?.trim();
2872
3993
  if (!raw)
2873
- return json3({ error: "dir required" }, 400);
2874
- const dir = raw === "~" || raw.startsWith("~/") ? join4(homedir2(), raw.slice(1)) : raw;
3994
+ return json12({ error: "dir required" }, 400);
3995
+ const dir = raw === "~" || raw.startsWith("~/") ? join4(homedir3(), raw.slice(1)) : raw;
2875
3996
  const next = { ...getConfig(), backupDir: dir };
2876
3997
  setConfig(next);
2877
3998
  try {
2878
3999
  atomicWrite(getConfigSource().path, serializeConfig(next));
2879
4000
  } catch (e) {
2880
- return json3({
4001
+ return json12({
2881
4002
  ok: true,
2882
4003
  dir: backupDir(),
2883
4004
  persisted: false,
2884
- warning: `applied live but not saved: ${e instanceof Error ? e.message : String(e)}`
4005
+ warning: `applied live but not saved: ${clientError(e)}`
2885
4006
  });
2886
4007
  }
2887
4008
  recordVersion(getConfig(), "auto", Date.now(), "backup path changed");
2888
- return json3({ ok: true, dir: backupDir(), persisted: true });
4009
+ return json12({ ok: true, dir: backupDir(), persisted: true });
2889
4010
  }
2890
4011
  if (p === "/api/backups/get" && req.method === "GET") {
2891
4012
  const name = url.searchParams.get("name");
2892
4013
  if (!name)
2893
- return json3({ error: "name required" }, 400);
4014
+ return json12({ error: "name required" }, 400);
2894
4015
  try {
2895
- return json3({ name, content: readBackup(name) });
4016
+ return json12({ name, content: readBackup(name) });
2896
4017
  } catch {
2897
- return json3({ error: "not found" }, 404);
4018
+ return json12({ error: "not found" }, 404);
2898
4019
  }
2899
4020
  }
2900
4021
  if (p === "/api/backups/raw" && req.method === "GET") {
@@ -2915,41 +4036,41 @@ async function featureRoutes(req, url) {
2915
4036
  if (p === "/api/backups/upload" && req.method === "POST") {
2916
4037
  const b = await readJson(req);
2917
4038
  if (!b?.name || typeof b.content !== "string") {
2918
- return json3({ error: "name and content are required" }, 400);
4039
+ return json12({ error: "name and content are required" }, 400);
2919
4040
  }
2920
4041
  try {
2921
4042
  const safe = b.name.endsWith(".rsc") ? b.name : `${b.name}.rsc`;
2922
- return json3({ ok: true, name: writeBackup(safe, b.content) });
4043
+ return json12({ ok: true, name: writeBackup(safe, b.content) });
2923
4044
  } catch (e) {
2924
- return json3({ error: e instanceof Error ? e.message : String(e) }, 400);
4045
+ return json12({ error: clientError(e) }, 400);
2925
4046
  }
2926
4047
  }
2927
4048
  if (p === "/api/backups/rename" && req.method === "POST") {
2928
4049
  const b = await readJson(req);
2929
4050
  if (!b?.name || !b?.new_name)
2930
- return json3({ error: "name and new_name are required" }, 400);
4051
+ return json12({ error: "name and new_name are required" }, 400);
2931
4052
  try {
2932
- return json3({ ok: true, name: renameBackup(b.name, b.new_name) });
4053
+ return json12({ ok: true, name: renameBackup(b.name, b.new_name) });
2933
4054
  } catch (e) {
2934
- return json3({ error: e instanceof Error ? e.message : String(e) }, 400);
4055
+ return json12({ error: clientError(e) }, 400);
2935
4056
  }
2936
4057
  }
2937
4058
  if (p === "/api/backups/delete" && req.method === "POST") {
2938
4059
  const b = await readJson(req);
2939
4060
  if (!b?.name)
2940
- return json3({ error: "name required" }, 400);
4061
+ return json12({ error: "name required" }, 400);
2941
4062
  try {
2942
- 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);
2943
4064
  } catch (e) {
2944
- return json3({ error: e instanceof Error ? e.message : String(e) }, 400);
4065
+ return json12({ error: clientError(e) }, 400);
2945
4066
  }
2946
4067
  }
2947
4068
  if (p === "/api/backups/restore" && req.method === "POST") {
2948
4069
  const b = await readJson(req);
2949
4070
  if (!b?.name)
2950
- return json3({ error: "name required" }, 400);
4071
+ return json12({ error: "name required" }, 400);
2951
4072
  const device = resolveDeviceName(b.device);
2952
- return json3(await restoreLocalBackup(device, b.name, b.confirm === true));
4073
+ return json12(await restoreLocalBackup(device, b.name, b.confirm === true));
2953
4074
  }
2954
4075
  if (p === "/api/backups/create" && req.method === "POST") {
2955
4076
  const b = await readJson(req);
@@ -2961,14 +4082,14 @@ async function featureRoutes(req, url) {
2961
4082
  compact: b?.compact === true,
2962
4083
  terse: b?.terse === true
2963
4084
  });
2964
- 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);
2965
4086
  }
2966
4087
  return null;
2967
4088
  }
2968
4089
  async function runDashboard(cfg, transportLabel) {
2969
- const store = await openSqliteStore(cfg.dbPath);
4090
+ const store3 = await openSqliteStore(cfg.dbPath);
2970
4091
  configureRecorder({
2971
- store,
4092
+ store: store3,
2972
4093
  capture: {
2973
4094
  captureBody: cfg.captureBody,
2974
4095
  maxBodyBytes: cfg.maxBodyBytes,
@@ -2980,13 +4101,13 @@ async function runDashboard(cfg, transportLabel) {
2980
4101
  startHealthChecks(30000);
2981
4102
  startGeoLookups();
2982
4103
  try {
2983
- usageStore = await openUsageStore(join4(dirname5(cfg.dbPath), "usage.db"));
4104
+ usageStore = await openUsageStore(join4(dirname7(cfg.dbPath), "usage.db"));
2984
4105
  startUsageSampler(usageStore);
2985
4106
  } catch (e) {
2986
4107
  logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
2987
4108
  }
2988
4109
  try {
2989
- capsmanStore = await openCapsmanStore(join4(dirname5(cfg.dbPath), "capsman.db"));
4110
+ capsmanStore = await openCapsmanStore(join4(dirname7(cfg.dbPath), "capsman.db"));
2990
4111
  startCapsmanSampler(capsmanStore);
2991
4112
  } catch (e) {
2992
4113
  logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
@@ -3060,12 +4181,39 @@ async function runDashboard(cfg, transportLabel) {
3060
4181
  const driftResp = await driftRoutes(req, url);
3061
4182
  if (driftResp)
3062
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;
3063
4211
  const memoryResp = await memoryRoutes(req, url);
3064
4212
  if (memoryResp)
3065
4213
  return memoryResp;
3066
4214
  const db = getEventStore();
3067
4215
  if (!db)
3068
- return json3({ error: "recorder not active" }, 503);
4216
+ return json12({ error: "recorder not active" }, 503);
3069
4217
  if (url.pathname === "/api/events" && req.method === "DELETE") {
3070
4218
  let body = {};
3071
4219
  try {
@@ -3073,7 +4221,7 @@ async function runDashboard(cfg, transportLabel) {
3073
4221
  } catch {}
3074
4222
  const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
3075
4223
  const removed = body.all === true ? db.clear() : db.delete(ids);
3076
- return json3({ removed, total: db.total() });
4224
+ return json12({ removed, total: db.total() });
3077
4225
  }
3078
4226
  if (url.pathname === "/api/ssh-pool") {
3079
4227
  const cfg2 = getConfig();
@@ -3082,7 +4230,7 @@ async function runDashboard(cfg, transportLabel) {
3082
4230
  const totalInflight = ps.reduce((s, p) => s + p.inflight, 0);
3083
4231
  const totalIdle = ps.filter((p) => p.idle).length;
3084
4232
  const totalBusy = ps.filter((p) => p.inflight > 0).length;
3085
- return json3({
4233
+ return json12({
3086
4234
  enabled,
3087
4235
  config: {
3088
4236
  keepAlive: cfg2.ssh.keepAlive,
@@ -3111,16 +4259,39 @@ async function runDashboard(cfg, transportLabel) {
3111
4259
  });
3112
4260
  }
3113
4261
  if (url.pathname === "/api/devices") {
3114
- 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
+ }
3115
4286
  }
3116
4287
  if (url.pathname === "/api/devices/toggle" && req.method === "POST") {
3117
4288
  const b = await readJson(req);
3118
4289
  if (typeof b?.device !== "string" || typeof b?.disabled !== "boolean") {
3119
- return json3({ error: "device (string) and disabled (boolean) are required" }, 400);
4290
+ return json12({ error: "device (string) and disabled (boolean) are required" }, 400);
3120
4291
  }
3121
4292
  const cfg2 = getConfig();
3122
4293
  if (!(b.device in cfg2.devices))
3123
- return json3({ error: `unknown device: ${b.device}` }, 404);
4294
+ return json12({ error: `unknown device: ${b.device}` }, 404);
3124
4295
  const dc = cfg2.devices[b.device];
3125
4296
  const next = {
3126
4297
  ...cfg2,
@@ -3136,12 +4307,12 @@ async function runDashboard(cfg, transportLabel) {
3136
4307
  atomicWrite(getConfigSource().path, serializeConfig(next));
3137
4308
  } catch (e) {
3138
4309
  persisted = false;
3139
- warning = `applied live but not saved to disk: ${e instanceof Error ? e.message : String(e)}`;
4310
+ warning = `applied live but not saved to disk: ${clientError(e)}`;
3140
4311
  }
3141
4312
  if (persisted) {
3142
4313
  recordVersion(getConfig(), "auto", Date.now(), `device ${b.disabled ? "disabled" : "enabled"}: ${b.device}`);
3143
4314
  }
3144
- return json3({
4315
+ return json12({
3145
4316
  ok: true,
3146
4317
  persisted,
3147
4318
  requiresReconnect: true,
@@ -3154,17 +4325,17 @@ async function runDashboard(cfg, transportLabel) {
3154
4325
  const name = typeof b?.device === "string" ? b.device : "";
3155
4326
  const cfg2 = getConfig();
3156
4327
  if (!(name in cfg2.devices))
3157
- return json3({ error: `unknown device: ${name}` }, 404);
4328
+ return json12({ error: `unknown device: ${name}` }, 404);
3158
4329
  if (url.pathname === "/api/devices/reconnect")
3159
4330
  closeDevice(name);
3160
4331
  const status = await probeDevice(name, cfg2.devices[name]);
3161
- return json3({ ok: true, status, ...devicesPayload(db) });
4332
+ return json12({ ok: true, status, ...devicesPayload(db) });
3162
4333
  }
3163
4334
  if (url.pathname === "/api/topology") {
3164
- return json3(topologyPayload());
4335
+ return json12(topologyPayload());
3165
4336
  }
3166
4337
  if (url.pathname === "/api/config") {
3167
- return json3(configPayload());
4338
+ return json12(configPayload());
3168
4339
  }
3169
4340
  if (url.pathname === "/" || url.pathname === "/index.html") {
3170
4341
  return new Response(dashboardHtml(), {
@@ -3173,9 +4344,9 @@ async function runDashboard(cfg, transportLabel) {
3173
4344
  }
3174
4345
  if (url.pathname === "/api/releases/latest") {
3175
4346
  try {
3176
- return json3(await fetchLatestRelease());
4347
+ return json12(await fetchLatestRelease());
3177
4348
  } catch (e) {
3178
- return json3({ error: e instanceof Error ? e.message : "fetch failed" }, 502);
4349
+ return json12({ error: clientError(e, "fetch failed") }, 502);
3179
4350
  }
3180
4351
  }
3181
4352
  if (url.pathname === "/api/catalog" && req.method === "GET") {
@@ -3194,7 +4365,7 @@ async function runDashboard(cfg, transportLabel) {
3194
4365
  }));
3195
4366
  const prompts = listPrompts();
3196
4367
  const groups = [...new Set(modules.map((m) => m.group))].sort();
3197
- return json3({
4368
+ return json12({
3198
4369
  modules,
3199
4370
  prompts,
3200
4371
  groups,
@@ -3209,25 +4380,25 @@ async function runDashboard(cfg, transportLabel) {
3209
4380
  }
3210
4381
  if (url.pathname === "/api/releases" && req.method === "GET") {
3211
4382
  try {
3212
- return json3(await fetchAllReleases());
4383
+ return json12(await fetchAllReleases());
3213
4384
  } catch (e) {
3214
- return json3({ error: e instanceof Error ? e.message : "fetch failed" }, 502);
4385
+ return json12({ error: clientError(e, "fetch failed") }, 502);
3215
4386
  }
3216
4387
  }
3217
4388
  if (url.pathname === "/api/upgrade" && req.method === "POST") {
3218
4389
  const body = await readJson(req);
3219
4390
  const version = String(body?.version ?? "latest");
3220
4391
  if (version !== "latest" && !/^\d+\.\d+\.\d+$/.test(version)) {
3221
- 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);
3222
4393
  }
3223
4394
  const spec = `@usex/mikrotik-mcp@${version}`;
3224
4395
  logger.warn(`Dashboard requested upgrade: bun i -g ${spec}`);
3225
4396
  const { ok, log } = await runUpgrade(spec);
3226
4397
  if (!ok)
3227
- return json3({ ok: false, version, log }, 500);
4398
+ return json12({ ok: false, version, log }, 500);
3228
4399
  const willRestart = body?.restart !== false;
3229
4400
  const relaunched = willRestart ? restartProcess() : false;
3230
- return json3({
4401
+ return json12({
3231
4402
  ok: true,
3232
4403
  version,
3233
4404
  log,
@@ -3237,7 +4408,7 @@ async function runDashboard(cfg, transportLabel) {
3237
4408
  }
3238
4409
  if (url.pathname === "/api/meta") {
3239
4410
  const f = facets(db);
3240
- return json3({
4411
+ return json12({
3241
4412
  ...f,
3242
4413
  version: VERSION,
3243
4414
  risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
@@ -3248,19 +4419,19 @@ async function runDashboard(cfg, transportLabel) {
3248
4419
  }
3249
4420
  if (url.pathname === "/api/events") {
3250
4421
  const filter = filterFromQuery(url);
3251
- return json3({ events: db.query(filter), total: db.total() });
4422
+ return json12({ events: db.query(filter), total: db.total() });
3252
4423
  }
3253
4424
  const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
3254
4425
  if (eventMatch) {
3255
4426
  const e = db.get(decodeURIComponent(eventMatch[1]));
3256
- return e ? json3(e) : json3({ error: "not found" }, 404);
4427
+ return e ? json12(e) : json12({ error: "not found" }, 404);
3257
4428
  }
3258
4429
  if (url.pathname === "/api/stats") {
3259
4430
  const now = Date.now();
3260
- const windowMs = Number(url.searchParams.get("window") ?? 3600000);
4431
+ const windowMs2 = Number(url.searchParams.get("window") ?? 3600000);
3261
4432
  const buckets = Number(url.searchParams.get("buckets") ?? 60);
3262
- const events = db.query({ since: now - windowMs, limit: 5000 });
3263
- 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 }));
3264
4435
  }
3265
4436
  return new Response("Not Found", { status: 404 });
3266
4437
  }
@@ -3278,9 +4449,8 @@ async function runDashboard(cfg, transportLabel) {
3278
4449
  try {
3279
4450
  return await dashboardRoute(req, url, srv, configAdmin, transportLabel);
3280
4451
  } catch (e) {
3281
- const msg = e instanceof Error ? e.message : String(e);
3282
- logger.error(`Dashboard request failed (${url.pathname}): ${msg}`);
3283
- return json3({ error: msg }, 502);
4452
+ logger.error(`Dashboard request failed (${url.pathname}): ${logError(e)}`);
4453
+ return json12({ error: clientError(e) }, 502);
3284
4454
  }
3285
4455
  },
3286
4456
  websocket: {
@@ -3292,11 +4462,19 @@ async function runDashboard(cfg, transportLabel) {
3292
4462
  } catch {}
3293
4463
  });
3294
4464
  } else {
3295
- ws.data.unsub = subscribe((e) => {
4465
+ const send = (payload2) => {
3296
4466
  try {
3297
- ws.send(JSON.stringify({ type: "event", event: e }));
4467
+ ws.send(JSON.stringify(payload2));
3298
4468
  } catch {}
3299
- });
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
+ };
3300
4478
  }
3301
4479
  ws.send(JSON.stringify({ type: "hello", transport: transportLabel }));
3302
4480
  },
@@ -3317,14 +4495,14 @@ async function runDashboard(cfg, transportLabel) {
3317
4495
  }
3318
4496
  return {
3319
4497
  server,
3320
- store,
4498
+ store: store3,
3321
4499
  stop() {
3322
4500
  stopHealthChecks();
3323
4501
  stopGeoLookups();
3324
4502
  stopUsageSampler();
3325
4503
  stopCapsmanSampler();
3326
4504
  server.stop(true);
3327
- store.close();
4505
+ store3.close();
3328
4506
  usageStore?.close();
3329
4507
  usageStore = null;
3330
4508
  capsmanStore?.close();
@@ -3641,6 +4819,12 @@ async function runStdio() {
3641
4819
  const transport = new StdioServerTransport;
3642
4820
  await server.connect(transport);
3643
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
+ });
3644
4828
  if (!getConfig().disableUpdateCheck) {
3645
4829
  whisperUpdate(server).catch(() => {});
3646
4830
  }
@@ -3711,6 +4895,100 @@ async function printBanner() {
3711
4895
  }
3712
4896
  }
3713
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
+
3714
4992
  // src/cli.ts
3715
4993
  var HELP = `${SERVER_NAME} v${VERSION} \u2014 MikroTik RouterOS MCP server
3716
4994
 
@@ -3863,6 +5141,29 @@ async function main() {
3863
5141
  warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
3864
5142
  const deviceNames = Object.keys(cfg.devices);
3865
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
+ }
3866
5167
  let shuttingDown = false;
3867
5168
  const shutdown = (signal) => {
3868
5169
  if (shuttingDown)
@@ -3871,6 +5172,9 @@ async function main() {
3871
5172
  logger.info(`Received ${signal}, shutting down\u2026`);
3872
5173
  closeAll();
3873
5174
  closeMemoryStore();
5175
+ stopAlertSampler();
5176
+ stopScheduler();
5177
+ stopAttackDetection();
3874
5178
  process.exit(0);
3875
5179
  };
3876
5180
  process.on("SIGINT", () => shutdown("SIGINT"));