process-watchdog 1.0.0 → 1.2.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 (52) hide show
  1. package/BUILDING.md +64 -0
  2. package/README.md +115 -0
  3. package/dashboard/watchdog.html +406 -0
  4. package/dist/api/routes.d.ts.map +1 -1
  5. package/dist/api/routes.js +45 -0
  6. package/dist/api/routes.js.map +1 -1
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +31 -7
  9. package/dist/config.js.map +1 -1
  10. package/dist/index.js +21 -5
  11. package/dist/index.js.map +1 -1
  12. package/dist/platform/index.d.ts.map +1 -1
  13. package/dist/platform/index.js +7 -1
  14. package/dist/platform/index.js.map +1 -1
  15. package/dist/platform/linux.d.ts +14 -0
  16. package/dist/platform/linux.d.ts.map +1 -0
  17. package/dist/platform/linux.js +235 -0
  18. package/dist/platform/linux.js.map +1 -0
  19. package/dist/platform/macos.d.ts +14 -0
  20. package/dist/platform/macos.d.ts.map +1 -0
  21. package/dist/platform/macos.js +255 -0
  22. package/dist/platform/macos.js.map +1 -0
  23. package/dist/plugins/plugin-loader.d.ts +1 -1
  24. package/dist/plugins/plugin-loader.d.ts.map +1 -1
  25. package/dist/plugins/plugin-loader.js +46 -1
  26. package/dist/plugins/plugin-loader.js.map +1 -1
  27. package/dist/plugins/process-guard.d.ts.map +1 -1
  28. package/dist/plugins/process-guard.js +0 -15
  29. package/dist/plugins/process-guard.js.map +1 -1
  30. package/dist/tray/index.d.ts +2 -0
  31. package/dist/tray/index.d.ts.map +1 -0
  32. package/dist/tray/index.js +73 -0
  33. package/dist/tray/index.js.map +1 -0
  34. package/package.json +21 -2
  35. package/scripts/bundle.js +38 -0
  36. package/src/api/routes.ts +56 -0
  37. package/src/config.ts +31 -7
  38. package/src/index.ts +21 -5
  39. package/src/platform/index.ts +7 -1
  40. package/src/platform/linux.ts +255 -0
  41. package/src/platform/macos.ts +259 -0
  42. package/src/plugins/plugin-loader.ts +66 -1
  43. package/src/plugins/process-guard.ts +0 -15
  44. package/src/tray/index.ts +86 -0
  45. package/src/tray/tray.ps1 +156 -0
  46. package/tests/plugins/cpu-monitor.test.ts +5 -5
  47. package/tests/plugins/disk-health.test.ts +1 -1
  48. package/tests/plugins/fixtures/broken-plugin.js +5 -0
  49. package/tests/plugins/fixtures/custom-plugin.js +36 -0
  50. package/tests/plugins/fixtures/named-plugin.js +31 -0
  51. package/tests/plugins/plugin-loader.test.ts +124 -7
  52. package/tests/plugins/process-guard.test.ts +1 -1
package/BUILDING.md ADDED
@@ -0,0 +1,64 @@
1
+ # Building Standalone Installers
2
+
3
+ Process Watchdog can be packaged into self-contained executables that do not require Node.js to be installed on the target machine. The build pipeline uses **esbuild** to bundle the TypeScript output into a single CommonJS file, then **@yao-pkg/pkg** to wrap it with a Node.js runtime.
4
+
5
+ ## Prerequisites
6
+
7
+ All dependencies are already in `devDependencies` — run `npm install` once before building.
8
+
9
+ ## Build commands
10
+
11
+ | Command | Output |
12
+ |---|---|
13
+ | `npm run build:standalone:win` | `standalone/watchdog-win.exe` (Windows x64) |
14
+ | `npm run build:standalone:mac` | `standalone/watchdog-macos` (macOS x64) |
15
+ | `npm run build:standalone:linux` | `standalone/watchdog-linux` (Linux x64) |
16
+ | `npm run build:standalone` | All three targets |
17
+
18
+ Each command runs three steps automatically:
19
+
20
+ 1. **`npm run build`** — TypeScript compiler (`tsc`) compiles `src/` → `dist/`
21
+ 2. **`npm run bundle`** — esbuild bundles `dist/index.js` → `bundle/watchdog.cjs`
22
+ 3. **`pkg`** — wraps `bundle/watchdog.cjs` with a Node.js runtime into a single executable
23
+
24
+ ## Why the esbuild step?
25
+
26
+ `better-sqlite3` uses a native `.node` addon (`better_sqlite3.node`) that cannot be inlined by pkg. The esbuild step bundles everything else while leaving `better-sqlite3` and `node-windows` as external `require()` calls. pkg then carries the `.node` file as an **asset** (see `pkg.assets` in `package.json`) and patches the path at runtime so the executable finds it.
27
+
28
+ ## Native addon details
29
+
30
+ The `.node` file is embedded in the executable at:
31
+
32
+ ```
33
+ node_modules/better-sqlite3/build/Release/better_sqlite3.node
34
+ ```
35
+
36
+ pkg extracts it to a temporary directory at startup. The `pkg.assets` field in `package.json` controls which files are embedded:
37
+
38
+ ```json
39
+ "assets": [
40
+ "config/**/*",
41
+ "node_modules/better-sqlite3/build/Release/better_sqlite3.node"
42
+ ]
43
+ ```
44
+
45
+ ## Verifying the build
46
+
47
+ After a successful Windows build:
48
+
49
+ ```
50
+ ./standalone/watchdog-win.exe status
51
+ ```
52
+
53
+ The executable should print the current watchdog status without needing Node.js installed.
54
+
55
+ ## Output locations
56
+
57
+ - `bundle/` — intermediate esbuild output (gitignored)
58
+ - `standalone/` — final executables (gitignored)
59
+
60
+ Neither directory is committed to source control.
61
+
62
+ ## Cross-compilation notes
63
+
64
+ pkg downloads pre-built Node.js binaries for the target platform automatically on first use. Building a Linux or macOS binary on Windows is supported — pkg handles the download. However, native addons (`.node` files) are platform-specific: a `better_sqlite3.node` compiled on Windows will not work on Linux. For Linux/macOS targets, the addon must be compiled on that platform. Consider using CI (e.g., GitHub Actions) with platform-specific runners for production cross-platform builds.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Process Watchdog
2
+
3
+ [![npm version](https://img.shields.io/npm/v/process-watchdog.svg)](https://www.npmjs.com/package/process-watchdog)
4
+
5
+ Modular PC health agent for the aidev.com.au ecosystem.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - 5 built-in plugins: `process-guard`, `memory-monitor`, `disk-health`, `startup-optimizer`, `cpu-monitor`
12
+ - REST API on port 3400 (`/api/v1`)
13
+ - `watchdog` CLI tool
14
+ - Windows service support via `node-windows`
15
+ - aidev.com.au dashboard integration (totalRecall, mah)
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ npm install -g process-watchdog
23
+
24
+ watchdog status # one-shot health check
25
+ watchdog start # run continuously with scheduler
26
+ ```
27
+
28
+ ---
29
+
30
+ ## CLI Commands
31
+
32
+ | Command | Description |
33
+ |----------------------------|----------------------------------------------------|
34
+ | `watchdog start` | Load config, start scheduler, watch continuously |
35
+ | `watchdog stop` | Stop the running watchdog process |
36
+ | `watchdog status` | Run check() on each plugin and print a summary |
37
+ | `watchdog check [plugin]` | Detailed metrics for all or a specific plugin |
38
+ | `watchdog fix [plugin]` | Run fix() for all or a specific plugin |
39
+ | `watchdog api start` | Start the HTTP REST API server |
40
+ | `watchdog install-service` | Install watchdog as a Windows system service |
41
+ | `watchdog uninstall-service` | Uninstall the Windows system service |
42
+
43
+ ---
44
+
45
+ ## Plugins
46
+
47
+ | Plugin | Description | Default Interval | Auto-Fix |
48
+ |---------------------|----------------------------------------------|-----------------|----------|
49
+ | `process-guard` | Kills runaway node/cmd/bash processes | 5 min | Yes |
50
+ | `memory-monitor` | Alerts on high RAM usage | 2 min | No |
51
+ | `disk-health` | Checks disk usage and cleans temp files | 30 min | Yes |
52
+ | `startup-optimizer` | Audits startup programs | On demand | No |
53
+ | `cpu-monitor` | Alerts on sustained high CPU usage | 2 min | No |
54
+
55
+ ---
56
+
57
+ ## API Endpoints
58
+
59
+ All routes are prefixed with `/api/v1`.
60
+
61
+ | Method | Endpoint | Description |
62
+ |--------|------------------------|------------------------------------------------------|
63
+ | GET | `/health` | Overall service health and per-plugin status |
64
+ | GET | `/plugins` | List all plugins with config and last check time |
65
+ | GET | `/plugins/:name` | Plugin details and last 50 history entries |
66
+ | POST | `/plugins/:name/run` | Run `check` or `fix` on a plugin (`{ action }` body) |
67
+ | GET | `/history` | Check/fix history (query: `plugin`, `limit`) |
68
+ | GET | `/config` | Current active configuration |
69
+
70
+ ---
71
+
72
+ ## Configuration
73
+
74
+ **Default config** — `config/default.json` in the package.
75
+
76
+ **User override** — `~/.aidev/watchdog.json` (merged over defaults at startup).
77
+
78
+ Key options:
79
+
80
+ ```jsonc
81
+ {
82
+ "port": 3400,
83
+ "logLevel": "info",
84
+ "historyRetentionDays": 30,
85
+ "plugins": {
86
+ "process-guard": { "enabled": true, "interval": 300000, "autoFix": true },
87
+ "memory-monitor": { "enabled": true, "interval": 120000, "autoFix": false }
88
+ // ...
89
+ }
90
+ }
91
+ ```
92
+
93
+ History and the SQLite database are stored at `~/.aidev/watchdog.db`.
94
+
95
+ ---
96
+
97
+ ## Dashboard
98
+
99
+ Open `dashboard/watchdog.html` in a browser while the API server is running to view a live health overview compatible with aidev.com.au.
100
+
101
+ ---
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ npm run build # compile TypeScript → dist/
107
+ npm test # run tests with vitest
108
+ npm run dev # run from source with tsx (no build step)
109
+ ```
110
+
111
+ ---
112
+
113
+ ## License
114
+
115
+ MIT
@@ -372,6 +372,176 @@
372
372
  font-size: 13px;
373
373
  }
374
374
 
375
+ /* ── Config editor ── */
376
+ .config-plugin-block {
377
+ border: 1px solid var(--card-border);
378
+ border-radius: 8px;
379
+ margin-bottom: 14px;
380
+ overflow: hidden;
381
+ }
382
+
383
+ .config-plugin-block:last-of-type { margin-bottom: 0; }
384
+
385
+ .config-plugin-header {
386
+ background: rgba(79,195,247,0.06);
387
+ border-bottom: 1px solid var(--card-border);
388
+ padding: 10px 16px;
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 12px;
392
+ }
393
+
394
+ .config-plugin-name {
395
+ font-weight: 700;
396
+ font-size: 13px;
397
+ flex: 1;
398
+ color: var(--accent);
399
+ font-family: 'Consolas', 'SF Mono', monospace;
400
+ }
401
+
402
+ .config-plugin-body {
403
+ padding: 12px 16px;
404
+ display: grid;
405
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
406
+ gap: 10px 20px;
407
+ }
408
+
409
+ .config-field {
410
+ display: flex;
411
+ flex-direction: column;
412
+ gap: 4px;
413
+ }
414
+
415
+ .config-label {
416
+ font-size: 11px;
417
+ font-weight: 600;
418
+ color: var(--text-muted);
419
+ text-transform: uppercase;
420
+ letter-spacing: 0.06em;
421
+ }
422
+
423
+ .config-input {
424
+ background: var(--bg);
425
+ border: 1px solid var(--card-border);
426
+ border-radius: 5px;
427
+ color: var(--text);
428
+ font-size: 13px;
429
+ padding: 5px 9px;
430
+ width: 100%;
431
+ transition: border-color 0.15s;
432
+ font-family: inherit;
433
+ }
434
+
435
+ .config-input:focus {
436
+ outline: none;
437
+ border-color: var(--accent);
438
+ }
439
+
440
+ .config-thresholds {
441
+ grid-column: 1 / -1;
442
+ }
443
+
444
+ .config-threshold-row {
445
+ display: flex;
446
+ align-items: center;
447
+ gap: 6px;
448
+ margin-bottom: 6px;
449
+ }
450
+
451
+ .config-threshold-row:last-child { margin-bottom: 0; }
452
+
453
+ .config-threshold-key {
454
+ flex: 1;
455
+ min-width: 0;
456
+ }
457
+
458
+ .config-threshold-val {
459
+ width: 100px;
460
+ flex-shrink: 0;
461
+ }
462
+
463
+ /* Toggle switch */
464
+ .toggle-wrap {
465
+ display: flex;
466
+ align-items: center;
467
+ gap: 8px;
468
+ padding-top: 20px;
469
+ }
470
+
471
+ .toggle {
472
+ position: relative;
473
+ width: 36px;
474
+ height: 20px;
475
+ flex-shrink: 0;
476
+ }
477
+
478
+ .toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
479
+
480
+ .toggle-slider {
481
+ position: absolute;
482
+ inset: 0;
483
+ background: var(--card-border);
484
+ border-radius: 20px;
485
+ cursor: pointer;
486
+ transition: background 0.2s;
487
+ }
488
+
489
+ .toggle-slider::before {
490
+ content: '';
491
+ position: absolute;
492
+ width: 14px;
493
+ height: 14px;
494
+ left: 3px;
495
+ top: 3px;
496
+ background: var(--text-muted);
497
+ border-radius: 50%;
498
+ transition: transform 0.2s, background 0.2s;
499
+ }
500
+
501
+ .toggle input:checked + .toggle-slider { background: rgba(79,195,247,0.25); }
502
+ .toggle input:checked + .toggle-slider::before {
503
+ transform: translateX(16px);
504
+ background: var(--accent);
505
+ }
506
+
507
+ /* Config action bar */
508
+ .config-actions {
509
+ display: flex;
510
+ align-items: center;
511
+ gap: 12px;
512
+ margin-top: 16px;
513
+ }
514
+
515
+ .btn-save {
516
+ background: rgba(79,195,247,0.15);
517
+ border-color: var(--accent);
518
+ color: var(--accent);
519
+ padding: 8px 20px;
520
+ font-size: 13px;
521
+ }
522
+
523
+ .config-feedback {
524
+ font-size: 12px;
525
+ font-weight: 600;
526
+ padding: 4px 10px;
527
+ border-radius: 5px;
528
+ display: none;
529
+ }
530
+
531
+ .config-feedback.success {
532
+ display: inline-block;
533
+ background: rgba(102,187,106,0.15);
534
+ color: var(--healthy);
535
+ border: 1px solid var(--healthy);
536
+ }
537
+
538
+ .config-feedback.error {
539
+ display: inline-block;
540
+ background: rgba(239,83,80,0.15);
541
+ color: var(--critical);
542
+ border: 1px solid var(--critical);
543
+ }
544
+
375
545
  /* ── Footer ── */
376
546
  footer {
377
547
  text-align: center;
@@ -440,6 +610,18 @@
440
610
  </div>
441
611
  </section>
442
612
 
613
+ <section id="config-section">
614
+ <h2>Configuration</h2>
615
+ <div class="card" id="config-card">
616
+ <div class="history-empty" id="config-loading">Loading configuration…</div>
617
+ <div id="config-plugins" style="display:none;"></div>
618
+ <div class="config-actions" id="config-actions" style="display:none;">
619
+ <button class="btn btn-save" id="config-save-btn" type="button">Save Changes</button>
620
+ <span class="config-feedback" id="config-feedback"></span>
621
+ </div>
622
+ </div>
623
+ </section>
624
+
443
625
  </main>
444
626
 
445
627
  <footer>
@@ -843,11 +1025,235 @@
843
1025
  }
844
1026
  }
845
1027
 
1028
+ // ── Config editor ─────────────────────────────────────────────────────────
1029
+
1030
+ // Store the raw config so Save knows what to build from
1031
+ var _loadedConfig = null;
1032
+
1033
+ function buildConfigEditor(config) {
1034
+ _loadedConfig = config;
1035
+ var container = document.getElementById('config-plugins');
1036
+ while (container.firstChild) container.removeChild(container.firstChild);
1037
+
1038
+ var plugins = config.plugins || {};
1039
+
1040
+ Object.keys(plugins).forEach(function (pluginName) {
1041
+ var pcfg = plugins[pluginName];
1042
+
1043
+ var block = el('div', 'config-plugin-block');
1044
+
1045
+ // ── Header: plugin name + enabled toggle ──
1046
+ var header = el('div', 'config-plugin-header');
1047
+
1048
+ var nameEl = el('span', 'config-plugin-name');
1049
+ setText(nameEl, pluginName);
1050
+ header.appendChild(nameEl);
1051
+
1052
+ var enabledLabel = el('span', 'config-label');
1053
+ setText(enabledLabel, 'Enabled');
1054
+ enabledLabel.style.marginRight = '6px';
1055
+
1056
+ var toggleWrap = el('label', 'toggle');
1057
+ var toggleInput = el('input');
1058
+ toggleInput.type = 'checkbox';
1059
+ toggleInput.id = 'cfg-enabled-' + pluginName;
1060
+ toggleInput.checked = !!pcfg.enabled;
1061
+ var toggleSlider = el('span', 'toggle-slider');
1062
+ toggleWrap.appendChild(toggleInput);
1063
+ toggleWrap.appendChild(toggleSlider);
1064
+
1065
+ header.appendChild(enabledLabel);
1066
+ header.appendChild(toggleWrap);
1067
+ block.appendChild(header);
1068
+
1069
+ // ── Body: interval + autoFix + thresholds ──
1070
+ var body = el('div', 'config-plugin-body');
1071
+
1072
+ // Interval (ms → seconds)
1073
+ if (pcfg.interval !== undefined) {
1074
+ var intervalField = el('div', 'config-field');
1075
+ var intervalLabel = el('label', 'config-label');
1076
+ setText(intervalLabel, 'Interval (seconds)');
1077
+ intervalLabel.htmlFor = 'cfg-interval-' + pluginName;
1078
+ var intervalInput = el('input', 'config-input');
1079
+ intervalInput.type = 'number';
1080
+ intervalInput.id = 'cfg-interval-' + pluginName;
1081
+ intervalInput.min = '0';
1082
+ intervalInput.value = String(Math.round(pcfg.interval / 1000));
1083
+ intervalField.appendChild(intervalLabel);
1084
+ intervalField.appendChild(intervalInput);
1085
+ body.appendChild(intervalField);
1086
+ }
1087
+
1088
+ // AutoFix toggle
1089
+ if (pcfg.autoFix !== undefined) {
1090
+ var autoFixField = el('div', 'config-field');
1091
+ var autoFixLabel = el('label', 'config-label');
1092
+ setText(autoFixLabel, 'Auto Fix');
1093
+ autoFixLabel.htmlFor = 'cfg-autofix-' + pluginName;
1094
+ var autoFixToggleWrap = el('div', 'toggle-wrap');
1095
+ var autoFixToggle = el('label', 'toggle');
1096
+ var autoFixInput = el('input');
1097
+ autoFixInput.type = 'checkbox';
1098
+ autoFixInput.id = 'cfg-autofix-' + pluginName;
1099
+ autoFixInput.checked = !!pcfg.autoFix;
1100
+ var autoFixSlider = el('span', 'toggle-slider');
1101
+ autoFixToggle.appendChild(autoFixInput);
1102
+ autoFixToggle.appendChild(autoFixSlider);
1103
+ autoFixToggleWrap.appendChild(autoFixToggle);
1104
+ autoFixField.appendChild(autoFixLabel);
1105
+ autoFixField.appendChild(autoFixToggleWrap);
1106
+ body.appendChild(autoFixField);
1107
+ }
1108
+
1109
+ // Thresholds
1110
+ var thresholds = pcfg.thresholds || {};
1111
+ var threshKeys = Object.keys(thresholds);
1112
+ if (threshKeys.length > 0) {
1113
+ var threshField = el('div', 'config-field config-thresholds');
1114
+ var threshHeading = el('span', 'config-label');
1115
+ setText(threshHeading, 'Thresholds');
1116
+ threshField.appendChild(threshHeading);
1117
+
1118
+ threshKeys.forEach(function (key) {
1119
+ var row = el('div', 'config-threshold-row');
1120
+
1121
+ var keyInput = el('input', 'config-input config-threshold-key');
1122
+ keyInput.type = 'text';
1123
+ keyInput.value = key;
1124
+ keyInput.readOnly = true;
1125
+ keyInput.style.color = 'var(--text-muted)';
1126
+
1127
+ var valInput = el('input', 'config-input config-threshold-val');
1128
+ valInput.type = 'number';
1129
+ valInput.id = 'cfg-thresh-' + pluginName + '-' + key;
1130
+ valInput.value = String(thresholds[key]);
1131
+ valInput.dataset.plugin = pluginName;
1132
+ valInput.dataset.key = key;
1133
+
1134
+ row.appendChild(keyInput);
1135
+ row.appendChild(valInput);
1136
+ threshField.appendChild(row);
1137
+ });
1138
+
1139
+ body.appendChild(threshField);
1140
+ }
1141
+
1142
+ block.appendChild(body);
1143
+ container.appendChild(block);
1144
+ });
1145
+
1146
+ document.getElementById('config-loading').style.display = 'none';
1147
+ container.style.display = 'block';
1148
+ document.getElementById('config-actions').style.display = 'flex';
1149
+ }
1150
+
1151
+ function collectConfigPayload() {
1152
+ if (!_loadedConfig) return null;
1153
+ var plugins = _loadedConfig.plugins || {};
1154
+ var payload = { plugins: {} };
1155
+
1156
+ Object.keys(plugins).forEach(function (pluginName) {
1157
+ var pcfg = plugins[pluginName];
1158
+ var out = {};
1159
+
1160
+ // enabled
1161
+ var enabledEl = document.getElementById('cfg-enabled-' + pluginName);
1162
+ if (enabledEl) out.enabled = enabledEl.checked;
1163
+
1164
+ // interval (seconds → ms)
1165
+ var intervalEl = document.getElementById('cfg-interval-' + pluginName);
1166
+ if (intervalEl) {
1167
+ var secs = parseFloat(intervalEl.value);
1168
+ out.interval = isNaN(secs) ? pcfg.interval : Math.round(secs * 1000);
1169
+ }
1170
+
1171
+ // autoFix
1172
+ var autoFixEl = document.getElementById('cfg-autofix-' + pluginName);
1173
+ if (autoFixEl) out.autoFix = autoFixEl.checked;
1174
+
1175
+ // thresholds
1176
+ var thresholds = pcfg.thresholds || {};
1177
+ if (Object.keys(thresholds).length > 0) {
1178
+ out.thresholds = {};
1179
+ Object.keys(thresholds).forEach(function (key) {
1180
+ var valEl = document.getElementById('cfg-thresh-' + pluginName + '-' + key);
1181
+ if (valEl) {
1182
+ var num = parseFloat(valEl.value);
1183
+ out.thresholds[key] = isNaN(num) ? thresholds[key] : num;
1184
+ } else {
1185
+ out.thresholds[key] = thresholds[key];
1186
+ }
1187
+ });
1188
+ }
1189
+
1190
+ payload.plugins[pluginName] = out;
1191
+ });
1192
+
1193
+ return payload;
1194
+ }
1195
+
1196
+ function showConfigFeedback(type, message) {
1197
+ var fb = document.getElementById('config-feedback');
1198
+ fb.className = 'config-feedback ' + type;
1199
+ setText(fb, message);
1200
+ clearTimeout(fb._hideTimer);
1201
+ fb._hideTimer = setTimeout(function () {
1202
+ fb.className = 'config-feedback';
1203
+ setText(fb, '');
1204
+ }, 4000);
1205
+ }
1206
+
1207
+ async function fetchConfig() {
1208
+ try {
1209
+ var res = await fetch(API_BASE + '/config', { cache: 'no-store' });
1210
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1211
+ var data = await res.json();
1212
+ buildConfigEditor(data);
1213
+ } catch (err) {
1214
+ console.warn('[watchdog] fetchConfig error:', err.message);
1215
+ var loadingEl = document.getElementById('config-loading');
1216
+ setText(loadingEl, 'Unable to load configuration.');
1217
+ }
1218
+ }
1219
+
1220
+ async function saveConfig() {
1221
+ var saveBtn = document.getElementById('config-save-btn');
1222
+ saveBtn.disabled = true;
1223
+ var payload = collectConfigPayload();
1224
+ if (!payload) {
1225
+ showConfigFeedback('error', 'Nothing to save.');
1226
+ saveBtn.disabled = false;
1227
+ return;
1228
+ }
1229
+ try {
1230
+ var res = await fetch(API_BASE + '/config', {
1231
+ method: 'PUT',
1232
+ headers: { 'Content-Type': 'application/json' },
1233
+ body: JSON.stringify(payload),
1234
+ });
1235
+ var data = await res.json();
1236
+ if (res.ok) {
1237
+ showConfigFeedback('success', 'Configuration saved.');
1238
+ buildConfigEditor(data);
1239
+ } else {
1240
+ showConfigFeedback('error', data.error || 'Save failed (HTTP ' + res.status + ').');
1241
+ }
1242
+ } catch (err) {
1243
+ showConfigFeedback('error', 'Save error: ' + err.message);
1244
+ } finally {
1245
+ saveBtn.disabled = false;
1246
+ }
1247
+ }
1248
+
1249
+ document.getElementById('config-save-btn').addEventListener('click', saveConfig);
1250
+
846
1251
  // ── Boot ─────────────────────────────────────────────────────────────────
847
1252
 
848
1253
  buildGaugeGrid();
849
1254
  fetchHealth();
850
1255
  fetchHistory();
1256
+ fetchConfig();
851
1257
  setInterval(function () { fetchHealth(); fetchHistory(); }, 15000);
852
1258
 
853
1259
  }());
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/api/routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAgB,MAAM,gCAAgC,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAc9C,wBAAgB,YAAY,CAC1B,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,cAAc,EAAE,EACzB,MAAM,EAAE,cAAc,GACrB,MAAM,CAqGR"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/api/routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAIjC,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAgB,MAAM,gCAAgC,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAc9C,wBAAgB,YAAY,CAC1B,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,cAAc,EAAE,EACzB,MAAM,EAAE,cAAc,GACrB,MAAM,CA0JR"}
@@ -1,4 +1,7 @@
1
1
  import { Router } from 'express';
2
+ import { writeFile, mkdir } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import os from 'os';
2
5
  const STATUS_ORDER = ['healthy', 'warning', 'critical'];
3
6
  function worstStatus(statuses) {
4
7
  let worst = 'healthy';
@@ -100,6 +103,48 @@ export function createRoutes(store, plugins, config) {
100
103
  router.get('/config', (_req, res) => {
101
104
  res.json(config);
102
105
  });
106
+ // PUT /config — deep-merge partial config and persist to ~/.aidev/watchdog.json
107
+ router.put('/config', async (req, res) => {
108
+ const body = req.body;
109
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
110
+ res.status(400).json({ error: 'Request body must be a JSON object' });
111
+ return;
112
+ }
113
+ try {
114
+ // Deep-merge the incoming partial config into the live config object
115
+ function isPlainObject(v) {
116
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
117
+ }
118
+ function deepMerge(target, source) {
119
+ const result = { ...target };
120
+ for (const key of Object.keys(source)) {
121
+ const src = source[key];
122
+ const tgt = result[key];
123
+ if (isPlainObject(src) && isPlainObject(tgt)) {
124
+ result[key] = deepMerge(tgt, src);
125
+ }
126
+ else {
127
+ result[key] = src;
128
+ }
129
+ }
130
+ return result;
131
+ }
132
+ const merged = deepMerge(config, body);
133
+ // Apply the merged values back onto the live config object so GET /config
134
+ // reflects the change immediately (without restart)
135
+ Object.assign(config, merged);
136
+ // Persist user overrides to ~/.aidev/watchdog.json
137
+ const aidevDir = join(os.homedir(), '.aidev');
138
+ await mkdir(aidevDir, { recursive: true });
139
+ const userConfigPath = join(aidevDir, 'watchdog.json');
140
+ await writeFile(userConfigPath, JSON.stringify(body, null, 2), 'utf-8');
141
+ res.json(config);
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ res.status(500).json({ error: message });
146
+ }
147
+ });
103
148
  return router;
104
149
  }
105
150
  //# sourceMappingURL=routes.js.map