process-watchdog 1.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.
- package/config/default.json +16 -0
- package/dashboard/watchdog.html +856 -0
- package/dist/api/routes.d.ts +6 -0
- package/dist/api/routes.d.ts.map +1 -0
- package/dist/api/routes.js +105 -0
- package/dist/api/routes.js.map +1 -0
- package/dist/api/server.d.ts +10 -0
- package/dist/api/server.d.ts.map +1 -0
- package/dist/api/server.js +16 -0
- package/dist/api/server.js.map +1 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +45 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon/processwatchdog.err.log +973 -0
- package/dist/daemon/processwatchdog.exe +0 -0
- package/dist/daemon/processwatchdog.exe.config +6 -0
- package/dist/daemon/processwatchdog.out.log +2 -0
- package/dist/daemon/processwatchdog.wrapper.log +18 -0
- package/dist/daemon/processwatchdog.xml +30 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +203 -0
- package/dist/index.js.map +1 -0
- package/dist/installer/service.d.ts +3 -0
- package/dist/installer/service.d.ts.map +1 -0
- package/dist/installer/service.js +41 -0
- package/dist/installer/service.js.map +1 -0
- package/dist/integrations/mah.d.ts +3 -0
- package/dist/integrations/mah.d.ts.map +1 -0
- package/dist/integrations/mah.js +22 -0
- package/dist/integrations/mah.js.map +1 -0
- package/dist/integrations/total-recall.d.ts +3 -0
- package/dist/integrations/total-recall.d.ts.map +1 -0
- package/dist/integrations/total-recall.js +22 -0
- package/dist/integrations/total-recall.js.map +1 -0
- package/dist/platform/index.d.ts +4 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +10 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/platform.interface.d.ts +42 -0
- package/dist/platform/platform.interface.d.ts.map +1 -0
- package/dist/platform/platform.interface.js +2 -0
- package/dist/platform/platform.interface.js.map +1 -0
- package/dist/platform/windows.d.ts +14 -0
- package/dist/platform/windows.d.ts.map +1 -0
- package/dist/platform/windows.js +162 -0
- package/dist/platform/windows.js.map +1 -0
- package/dist/plugins/cpu-monitor.d.ts +11 -0
- package/dist/plugins/cpu-monitor.d.ts.map +1 -0
- package/dist/plugins/cpu-monitor.js +57 -0
- package/dist/plugins/cpu-monitor.js.map +1 -0
- package/dist/plugins/disk-health.d.ts +11 -0
- package/dist/plugins/disk-health.d.ts.map +1 -0
- package/dist/plugins/disk-health.js +111 -0
- package/dist/plugins/disk-health.js.map +1 -0
- package/dist/plugins/memory-monitor.d.ts +11 -0
- package/dist/plugins/memory-monitor.d.ts.map +1 -0
- package/dist/plugins/memory-monitor.js +61 -0
- package/dist/plugins/memory-monitor.js.map +1 -0
- package/dist/plugins/plugin-loader.d.ts +4 -0
- package/dist/plugins/plugin-loader.d.ts.map +1 -0
- package/dist/plugins/plugin-loader.js +25 -0
- package/dist/plugins/plugin-loader.js.map +1 -0
- package/dist/plugins/plugin.interface.d.ts +28 -0
- package/dist/plugins/plugin.interface.d.ts.map +1 -0
- package/dist/plugins/plugin.interface.js +2 -0
- package/dist/plugins/plugin.interface.js.map +1 -0
- package/dist/plugins/process-guard.d.ts +11 -0
- package/dist/plugins/process-guard.d.ts.map +1 -0
- package/dist/plugins/process-guard.js +139 -0
- package/dist/plugins/process-guard.js.map +1 -0
- package/dist/plugins/startup-optimizer.d.ts +11 -0
- package/dist/plugins/startup-optimizer.d.ts.map +1 -0
- package/dist/plugins/startup-optimizer.js +78 -0
- package/dist/plugins/startup-optimizer.js.map +1 -0
- package/dist/scheduler.d.ts +16 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +46 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/store/history.d.ts +20 -0
- package/dist/store/history.d.ts.map +1 -0
- package/dist/store/history.js +60 -0
- package/dist/store/history.js.map +1 -0
- package/package.json +35 -0
- package/src/api/routes.ts +123 -0
- package/src/api/server.ts +20 -0
- package/src/config.ts +78 -0
- package/src/index.ts +228 -0
- package/src/installer/service.ts +50 -0
- package/src/integrations/mah.ts +22 -0
- package/src/integrations/total-recall.ts +27 -0
- package/src/platform/index.ts +13 -0
- package/src/platform/platform.interface.ts +46 -0
- package/src/platform/windows.ts +242 -0
- package/src/plugins/cpu-monitor.ts +67 -0
- package/src/plugins/disk-health.ts +128 -0
- package/src/plugins/memory-monitor.ts +70 -0
- package/src/plugins/plugin-loader.ts +27 -0
- package/src/plugins/plugin.interface.ts +31 -0
- package/src/plugins/process-guard.ts +165 -0
- package/src/plugins/startup-optimizer.ts +103 -0
- package/src/scheduler.ts +53 -0
- package/src/store/history.ts +90 -0
- package/tests/api/routes.test.ts +113 -0
- package/tests/config.test.ts +24 -0
- package/tests/platform/windows.test.ts +59 -0
- package/tests/plugins/cpu-monitor.test.ts +69 -0
- package/tests/plugins/disk-health.test.ts +69 -0
- package/tests/plugins/memory-monitor.test.ts +57 -0
- package/tests/plugins/plugin-loader.test.ts +35 -0
- package/tests/plugins/process-guard.test.ts +40 -0
- package/tests/plugins/startup-optimizer.test.ts +50 -0
- package/tests/scheduler.test.ts +69 -0
- package/tests/store/history.test.ts +89 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { WatchdogPlugin, HealthCheck, FixResult, PluginConfig } from './plugin.interface.js';
|
|
2
|
+
import { getPlatform, PlatformAdapter, ProcessInfo } from '../platform/index.js';
|
|
3
|
+
|
|
4
|
+
const TARGET_NAMES = ['node', 'cmd', 'bash'] as const;
|
|
5
|
+
type TargetName = (typeof TARGET_NAMES)[number];
|
|
6
|
+
|
|
7
|
+
/** Safely get processes by name — returns [] if none found or platform throws */
|
|
8
|
+
async function safeGetProcessesByName(platform: PlatformAdapter, name: string): Promise<ProcessInfo[]> {
|
|
9
|
+
try {
|
|
10
|
+
return await platform.getProcessesByName(name);
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ProcessGuardPlugin implements WatchdogPlugin {
|
|
17
|
+
name = 'process-guard';
|
|
18
|
+
description = 'Detect and kill orphaned processes before they accumulate';
|
|
19
|
+
defaultInterval = 300000;
|
|
20
|
+
private config: PluginConfig = {
|
|
21
|
+
enabled: true,
|
|
22
|
+
interval: 300000,
|
|
23
|
+
autoFix: true,
|
|
24
|
+
thresholds: { node: 50, cmd: 20, bash: 10 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
configure(opts: PluginConfig): void {
|
|
28
|
+
this.config = { ...this.config, ...opts };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async check(): Promise<HealthCheck> {
|
|
32
|
+
const platform = getPlatform();
|
|
33
|
+
const listeningPids = new Set(await platform.getListeningPids());
|
|
34
|
+
|
|
35
|
+
const counts: Record<string, number> = {};
|
|
36
|
+
const orphanCounts: Record<string, number> = {};
|
|
37
|
+
const warnings: string[] = [];
|
|
38
|
+
const suggestedActions: string[] = [];
|
|
39
|
+
|
|
40
|
+
for (const name of TARGET_NAMES) {
|
|
41
|
+
const procs = await safeGetProcessesByName(platform, name);
|
|
42
|
+
const total = procs.length;
|
|
43
|
+
const orphans = procs.filter((p) => !listeningPids.has(p.pid)).length;
|
|
44
|
+
|
|
45
|
+
counts[`${name}Count`] = total;
|
|
46
|
+
orphanCounts[`${name}Orphans`] = orphans;
|
|
47
|
+
|
|
48
|
+
const threshold = this.config.thresholds[name] ?? Infinity;
|
|
49
|
+
if (total > threshold) {
|
|
50
|
+
warnings.push(`${name}: ${total} processes exceeds threshold of ${threshold}`);
|
|
51
|
+
if (orphans > 0) {
|
|
52
|
+
suggestedActions.push(`Kill ${orphans} orphaned ${name} processes`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const totalOrphans =
|
|
58
|
+
(orphanCounts['nodeOrphans'] ?? 0) +
|
|
59
|
+
(orphanCounts['cmdOrphans'] ?? 0) +
|
|
60
|
+
(orphanCounts['bashOrphans'] ?? 0);
|
|
61
|
+
|
|
62
|
+
const metrics: Record<string, number> = {
|
|
63
|
+
nodeCount: counts['nodeCount'] ?? 0,
|
|
64
|
+
cmdCount: counts['cmdCount'] ?? 0,
|
|
65
|
+
bashCount: counts['bashCount'] ?? 0,
|
|
66
|
+
nodeOrphans: orphanCounts['nodeOrphans'] ?? 0,
|
|
67
|
+
cmdOrphans: orphanCounts['cmdOrphans'] ?? 0,
|
|
68
|
+
bashOrphans: orphanCounts['bashOrphans'] ?? 0,
|
|
69
|
+
totalOrphans,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let status: HealthCheck['status'];
|
|
73
|
+
if (totalOrphans > 100) {
|
|
74
|
+
status = 'critical';
|
|
75
|
+
} else if (totalOrphans > 0 && warnings.length > 0) {
|
|
76
|
+
status = 'warning';
|
|
77
|
+
} else {
|
|
78
|
+
status = 'healthy';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const message =
|
|
82
|
+
warnings.length > 0 ? warnings.join('; ') : 'All process counts within thresholds';
|
|
83
|
+
|
|
84
|
+
return { status, metrics, message, suggestedActions };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async fix(): Promise<FixResult> {
|
|
88
|
+
const platform = getPlatform();
|
|
89
|
+
const listeningPids = new Set(await platform.getListeningPids());
|
|
90
|
+
|
|
91
|
+
// Capture before metrics
|
|
92
|
+
const beforeMetrics: Record<string, number> = {};
|
|
93
|
+
for (const name of TARGET_NAMES) {
|
|
94
|
+
const procs = await safeGetProcessesByName(platform, name);
|
|
95
|
+
beforeMetrics[`${name}Count`] = procs.length;
|
|
96
|
+
beforeMetrics[`${name}Orphans`] = procs.filter((p) => !listeningPids.has(p.pid)).length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const actionsKept: string[] = [];
|
|
100
|
+
let totalMemoryFreedMB = 0;
|
|
101
|
+
|
|
102
|
+
// Protect only: this process, its parent, and processes listening on ports (dev servers)
|
|
103
|
+
const selfPid = process.pid;
|
|
104
|
+
const parentPid = process.ppid;
|
|
105
|
+
const protectedPids = new Set<number>(
|
|
106
|
+
[selfPid, parentPid, ...listeningPids].filter((p): p is number => typeof p === 'number'),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Kill orphans only when process count exceeds threshold
|
|
110
|
+
for (const name of TARGET_NAMES) {
|
|
111
|
+
const procs = await safeGetProcessesByName(platform, name);
|
|
112
|
+
const threshold = this.config.thresholds[name] ?? Infinity;
|
|
113
|
+
|
|
114
|
+
if (procs.length <= threshold) continue; // Within limits — skip
|
|
115
|
+
|
|
116
|
+
const orphans = procs.filter((p) => !listeningPids.has(p.pid) && !protectedPids.has(p.pid));
|
|
117
|
+
|
|
118
|
+
let killed = 0;
|
|
119
|
+
for (const proc of orphans) {
|
|
120
|
+
const success = await platform.killProcess(proc.pid);
|
|
121
|
+
if (success) {
|
|
122
|
+
killed++;
|
|
123
|
+
totalMemoryFreedMB += proc.memoryMB ?? 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (orphans.length > 0) {
|
|
128
|
+
actionsKept.push(`Killed ${killed}/${orphans.length} orphaned ${name} processes`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Best-effort: clean up low-CPU conhost processes (skip any that were running at snapshot time)
|
|
133
|
+
try {
|
|
134
|
+
const conhosts = await safeGetProcessesByName(platform, 'conhost');
|
|
135
|
+
for (const proc of conhosts) {
|
|
136
|
+
if (proc.cpu < 0.1 && !protectedPids.has(proc.pid)) {
|
|
137
|
+
const success = await platform.killProcess(proc.pid);
|
|
138
|
+
if (success) {
|
|
139
|
+
totalMemoryFreedMB += proc.memoryMB ?? 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// best-effort, ignore errors
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Capture after metrics (best-effort — system may be in transition after kills)
|
|
148
|
+
let afterListeningPids: Set<number>;
|
|
149
|
+
try {
|
|
150
|
+
afterListeningPids = new Set(await platform.getListeningPids());
|
|
151
|
+
} catch {
|
|
152
|
+
afterListeningPids = new Set<number>();
|
|
153
|
+
}
|
|
154
|
+
const afterMetrics: Record<string, number> = {};
|
|
155
|
+
for (const name of TARGET_NAMES) {
|
|
156
|
+
const procs = await safeGetProcessesByName(platform, name);
|
|
157
|
+
afterMetrics[`${name}Count`] = procs.length;
|
|
158
|
+
afterMetrics[`${name}Orphans`] = procs.filter((p) => !afterListeningPids.has(p.pid)).length;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resourcesFreed = `Freed ~${Math.round(totalMemoryFreedMB)} MB RAM`;
|
|
162
|
+
|
|
163
|
+
return { actionsKept, resourcesFreed, beforeMetrics, afterMetrics };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { WatchdogPlugin, HealthCheck, FixResult, PluginConfig } from './plugin.interface.js';
|
|
2
|
+
import { getPlatform } from '../platform/index.js';
|
|
3
|
+
|
|
4
|
+
const KNOWN_BLOAT = [
|
|
5
|
+
'Docker Desktop',
|
|
6
|
+
'GoogleDriveFS',
|
|
7
|
+
'Figma Agent',
|
|
8
|
+
'CiscoSpark',
|
|
9
|
+
'CiscoMeetingDaemon',
|
|
10
|
+
'EasySettingBox',
|
|
11
|
+
'com.todesktop',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export class StartupOptimizerPlugin implements WatchdogPlugin {
|
|
15
|
+
name = 'startup-optimizer';
|
|
16
|
+
description = 'Manage startup items to reduce boot resource usage';
|
|
17
|
+
defaultInterval = 0;
|
|
18
|
+
private config: PluginConfig = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
interval: 0,
|
|
21
|
+
autoFix: false,
|
|
22
|
+
thresholds: {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
configure(opts: PluginConfig): void {
|
|
26
|
+
this.config = { ...this.config, ...opts };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async check(): Promise<HealthCheck> {
|
|
30
|
+
const platform = getPlatform();
|
|
31
|
+
const items = await platform.getStartupItems();
|
|
32
|
+
|
|
33
|
+
const enabledItems = items.filter((item) => item.enabled);
|
|
34
|
+
const startupItemCount = enabledItems.length;
|
|
35
|
+
|
|
36
|
+
const bloatItems = enabledItems.filter((item) =>
|
|
37
|
+
KNOWN_BLOAT.some((bloat) => item.name.includes(bloat)),
|
|
38
|
+
);
|
|
39
|
+
const bloatItemCount = bloatItems.length;
|
|
40
|
+
|
|
41
|
+
const suggestedActions: string[] = [];
|
|
42
|
+
if (bloatItemCount > 0) {
|
|
43
|
+
suggestedActions.push(
|
|
44
|
+
`Disable ${bloatItemCount} bloat startup item(s): ${bloatItems.map((b) => b.name).join(', ')}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const status: HealthCheck['status'] = bloatItemCount > 3 ? 'warning' : 'healthy';
|
|
49
|
+
const message = `${startupItemCount} startup items enabled, ${bloatItemCount} identified as bloat`;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
status,
|
|
53
|
+
metrics: { startupItemCount, bloatItemCount },
|
|
54
|
+
message,
|
|
55
|
+
suggestedActions,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async fix(): Promise<FixResult> {
|
|
60
|
+
const platform = getPlatform();
|
|
61
|
+
|
|
62
|
+
const itemsBefore = await platform.getStartupItems();
|
|
63
|
+
const enabledBefore = itemsBefore.filter((item) => item.enabled);
|
|
64
|
+
const bloatBefore = enabledBefore.filter((item) =>
|
|
65
|
+
KNOWN_BLOAT.some((bloat) => item.name.includes(bloat)),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const beforeMetrics: Record<string, number> = {
|
|
69
|
+
startupItemCount: enabledBefore.length,
|
|
70
|
+
bloatItemCount: bloatBefore.length,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const actionsKept: string[] = [];
|
|
74
|
+
let disabledCount = 0;
|
|
75
|
+
|
|
76
|
+
for (const item of bloatBefore) {
|
|
77
|
+
try {
|
|
78
|
+
const success = await platform.disableStartupItem(item.name);
|
|
79
|
+
if (success) {
|
|
80
|
+
disabledCount++;
|
|
81
|
+
actionsKept.push(`Disabled startup item: ${item.name}`);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// skip items that fail to disable
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const itemsAfter = await platform.getStartupItems();
|
|
89
|
+
const enabledAfter = itemsAfter.filter((item) => item.enabled);
|
|
90
|
+
const bloatAfter = enabledAfter.filter((item) =>
|
|
91
|
+
KNOWN_BLOAT.some((bloat) => item.name.includes(bloat)),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const afterMetrics: Record<string, number> = {
|
|
95
|
+
startupItemCount: enabledAfter.length,
|
|
96
|
+
bloatItemCount: bloatAfter.length,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const resourcesFreed = `Disabled ${disabledCount} startup item(s)`;
|
|
100
|
+
|
|
101
|
+
return { actionsKept, resourcesFreed, beforeMetrics, afterMetrics };
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { WatchdogPlugin } from './plugins/plugin.interface.js';
|
|
2
|
+
|
|
3
|
+
interface SchedulerOptions {
|
|
4
|
+
autoFix: Record<string, boolean>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class Scheduler {
|
|
8
|
+
private plugins: WatchdogPlugin[];
|
|
9
|
+
private options: SchedulerOptions;
|
|
10
|
+
private timers: NodeJS.Timeout[] = [];
|
|
11
|
+
private running = false;
|
|
12
|
+
|
|
13
|
+
constructor(plugins: WatchdogPlugin[], options: SchedulerOptions) {
|
|
14
|
+
this.plugins = plugins;
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
start(overrideIntervalMs?: number): void {
|
|
19
|
+
if (this.running) return;
|
|
20
|
+
this.running = true;
|
|
21
|
+
|
|
22
|
+
for (const plugin of this.plugins) {
|
|
23
|
+
const interval = overrideIntervalMs ?? plugin.defaultInterval;
|
|
24
|
+
if (interval <= 0) continue;
|
|
25
|
+
|
|
26
|
+
const run = async () => {
|
|
27
|
+
if (!this.running) return;
|
|
28
|
+
try {
|
|
29
|
+
const result = await plugin.check();
|
|
30
|
+
const shouldFix = this.options.autoFix[plugin.name] &&
|
|
31
|
+
(result.status === 'critical' || result.status === 'warning');
|
|
32
|
+
if (shouldFix) {
|
|
33
|
+
await plugin.fix();
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`[watchdog] ${plugin.name} error:`, err);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
run(); // Run immediately
|
|
41
|
+
const timer = setInterval(run, interval);
|
|
42
|
+
this.timers.push(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
stop(): void {
|
|
47
|
+
this.running = false;
|
|
48
|
+
for (const timer of this.timers) clearInterval(timer);
|
|
49
|
+
this.timers = [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isRunning(): boolean { return this.running; }
|
|
53
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface HistoryRow {
|
|
4
|
+
id: number;
|
|
5
|
+
plugin: string;
|
|
6
|
+
type: string;
|
|
7
|
+
status: string;
|
|
8
|
+
metrics: string | null;
|
|
9
|
+
actions: string | null;
|
|
10
|
+
message: string | null;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class HistoryStore {
|
|
15
|
+
private db: Database.Database;
|
|
16
|
+
|
|
17
|
+
constructor(dbPath: string = './watchdog.db') {
|
|
18
|
+
this.db = new Database(dbPath);
|
|
19
|
+
this.db.pragma('journal_mode = WAL');
|
|
20
|
+
this.db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS history (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
plugin TEXT NOT NULL,
|
|
24
|
+
type TEXT NOT NULL,
|
|
25
|
+
status TEXT NOT NULL,
|
|
26
|
+
metrics TEXT,
|
|
27
|
+
actions TEXT,
|
|
28
|
+
message TEXT,
|
|
29
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
recordCheck(
|
|
35
|
+
plugin: string,
|
|
36
|
+
status: string,
|
|
37
|
+
metrics: Record<string, unknown>,
|
|
38
|
+
message: string,
|
|
39
|
+
): void {
|
|
40
|
+
const stmt = this.db.prepare(
|
|
41
|
+
`INSERT INTO history (plugin, type, status, metrics, message)
|
|
42
|
+
VALUES (?, 'check', ?, ?, ?)`,
|
|
43
|
+
);
|
|
44
|
+
stmt.run(plugin, status, JSON.stringify(metrics), message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
recordFix(plugin: string, actions: string[], resourcesFreed: string): void {
|
|
48
|
+
const stmt = this.db.prepare(
|
|
49
|
+
`INSERT INTO history (plugin, type, status, actions, message)
|
|
50
|
+
VALUES (?, 'fix', 'fixed', ?, ?)`,
|
|
51
|
+
);
|
|
52
|
+
stmt.run(plugin, JSON.stringify(actions), resourcesFreed);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getHistory(plugin: string | null, limit: number): HistoryRow[] {
|
|
56
|
+
if (plugin !== null) {
|
|
57
|
+
const stmt = this.db.prepare(
|
|
58
|
+
`SELECT * FROM history WHERE plugin = ? ORDER BY id DESC LIMIT ?`,
|
|
59
|
+
);
|
|
60
|
+
return stmt.all(plugin, limit) as HistoryRow[];
|
|
61
|
+
}
|
|
62
|
+
const stmt = this.db.prepare(
|
|
63
|
+
`SELECT * FROM history ORDER BY id DESC LIMIT ?`,
|
|
64
|
+
);
|
|
65
|
+
return stmt.all(limit) as HistoryRow[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getLastChecks(): Record<string, HistoryRow> {
|
|
69
|
+
const stmt = this.db.prepare(`
|
|
70
|
+
SELECT h.*
|
|
71
|
+
FROM history h
|
|
72
|
+
INNER JOIN (
|
|
73
|
+
SELECT plugin, MAX(id) AS max_id
|
|
74
|
+
FROM history
|
|
75
|
+
WHERE type = 'check'
|
|
76
|
+
GROUP BY plugin
|
|
77
|
+
) latest ON h.id = latest.max_id
|
|
78
|
+
`);
|
|
79
|
+
const rows = stmt.all() as HistoryRow[];
|
|
80
|
+
const result: Record<string, HistoryRow> = {};
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
result[row.plugin] = row;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close(): void {
|
|
88
|
+
this.db.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import supertest from 'supertest';
|
|
3
|
+
import { existsSync, unlinkSync } from 'fs';
|
|
4
|
+
import { HistoryStore } from '../../src/store/history.js';
|
|
5
|
+
import { WatchdogPlugin, HealthCheck, FixResult, PluginConfig } from '../../src/plugins/plugin.interface.js';
|
|
6
|
+
import { loadConfig } from '../../src/config.js';
|
|
7
|
+
import { createApp } from '../../src/api/server.js';
|
|
8
|
+
|
|
9
|
+
const TEST_DB = './test-routes-api.db';
|
|
10
|
+
|
|
11
|
+
class MockPlugin implements WatchdogPlugin {
|
|
12
|
+
name = 'process-guard';
|
|
13
|
+
description = 'Mock process guard plugin';
|
|
14
|
+
defaultInterval = 300000;
|
|
15
|
+
|
|
16
|
+
async check(): Promise<HealthCheck> {
|
|
17
|
+
return {
|
|
18
|
+
status: 'healthy',
|
|
19
|
+
metrics: { nodeCount: 2 },
|
|
20
|
+
message: 'All good',
|
|
21
|
+
suggestedActions: [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async fix(): Promise<FixResult> {
|
|
26
|
+
return {
|
|
27
|
+
actionsKept: [],
|
|
28
|
+
resourcesFreed: '0MB',
|
|
29
|
+
beforeMetrics: {},
|
|
30
|
+
afterMetrics: {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
configure(_opts: PluginConfig): void {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let request: ReturnType<typeof supertest>;
|
|
38
|
+
let store: HistoryStore;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
const config = await loadConfig({ port: 3400 });
|
|
42
|
+
store = new HistoryStore(TEST_DB);
|
|
43
|
+
// Seed with one record
|
|
44
|
+
store.recordCheck('process-guard', 'healthy', { nodeCount: 1 }, 'Seeded check');
|
|
45
|
+
|
|
46
|
+
const plugins: WatchdogPlugin[] = [new MockPlugin()];
|
|
47
|
+
const app = createApp(store, plugins, config);
|
|
48
|
+
request = supertest(app);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
store.close();
|
|
53
|
+
if (existsSync(TEST_DB)) {
|
|
54
|
+
unlinkSync(TEST_DB);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('GET /api/v1/health', () => {
|
|
59
|
+
it('returns 200 with service and plugins object', async () => {
|
|
60
|
+
const res = await request.get('/api/v1/health');
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
expect(res.body.service).toBe('process-watchdog');
|
|
63
|
+
expect(res.body).toHaveProperty('plugins');
|
|
64
|
+
expect(typeof res.body.plugins).toBe('object');
|
|
65
|
+
expect(res.body).toHaveProperty('status');
|
|
66
|
+
expect(res.body).toHaveProperty('uptime');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('GET /api/v1/plugins', () => {
|
|
71
|
+
it('returns array with length > 0 and each item has name', async () => {
|
|
72
|
+
const res = await request.get('/api/v1/plugins');
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
expect(Array.isArray(res.body)).toBe(true);
|
|
75
|
+
expect(res.body.length).toBeGreaterThan(0);
|
|
76
|
+
for (const plugin of res.body) {
|
|
77
|
+
expect(plugin).toHaveProperty('name');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('GET /api/v1/history', () => {
|
|
83
|
+
it('returns array', async () => {
|
|
84
|
+
const res = await request.get('/api/v1/history');
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
expect(Array.isArray(res.body)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('GET /api/v1/config', () => {
|
|
91
|
+
it('returns object with port 3400', async () => {
|
|
92
|
+
const res = await request.get('/api/v1/config');
|
|
93
|
+
expect(res.status).toBe(200);
|
|
94
|
+
expect(typeof res.body).toBe('object');
|
|
95
|
+
expect(res.body.port).toBe(3400);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('GET /api/v1/plugins/process-guard', () => {
|
|
100
|
+
it('returns 200 with name and history', async () => {
|
|
101
|
+
const res = await request.get('/api/v1/plugins/process-guard');
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
expect(res.body.name).toBe('process-guard');
|
|
104
|
+
expect(Array.isArray(res.body.history)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('GET /api/v1/plugins/nonexistent', () => {
|
|
109
|
+
it('returns 404', async () => {
|
|
110
|
+
const res = await request.get('/api/v1/plugins/nonexistent');
|
|
111
|
+
expect(res.status).toBe(404);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { loadConfig } from '../src/config.js';
|
|
3
|
+
|
|
4
|
+
describe('loadConfig', () => {
|
|
5
|
+
it('returns defaults when no user overrides', async () => {
|
|
6
|
+
const config = await loadConfig();
|
|
7
|
+
expect(config.port).toBe(3400);
|
|
8
|
+
expect(config.plugins['process-guard'].enabled).toBe(true);
|
|
9
|
+
expect(config.plugins['process-guard'].thresholds.node).toBe(50);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('deep-merges overrides without clobbering sibling keys', async () => {
|
|
13
|
+
const config = await loadConfig({
|
|
14
|
+
plugins: {
|
|
15
|
+
'process-guard': {
|
|
16
|
+
thresholds: { node: 100 },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
expect(config.plugins['process-guard'].thresholds.node).toBe(100);
|
|
21
|
+
// enabled should still be true from defaults
|
|
22
|
+
expect(config.plugins['process-guard'].enabled).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { WindowsPlatform } from '../../src/platform/windows.js';
|
|
3
|
+
|
|
4
|
+
const describeWindows = process.platform === 'win32' ? describe : describe.skip;
|
|
5
|
+
|
|
6
|
+
describeWindows('WindowsPlatform', () => {
|
|
7
|
+
const platform = new WindowsPlatform();
|
|
8
|
+
|
|
9
|
+
it('getProcessesByName returns node processes with required fields', async () => {
|
|
10
|
+
const procs = await platform.getProcessesByName('node');
|
|
11
|
+
expect(Array.isArray(procs)).toBe(true);
|
|
12
|
+
expect(procs.length).toBeGreaterThanOrEqual(1);
|
|
13
|
+
const first = procs[0];
|
|
14
|
+
expect(typeof first.pid).toBe('number');
|
|
15
|
+
expect(typeof first.name).toBe('string');
|
|
16
|
+
expect(typeof first.memoryMB).toBe('number');
|
|
17
|
+
expect(first.memoryMB).toBeGreaterThanOrEqual(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('getMemoryInfo returns valid memory stats', async () => {
|
|
21
|
+
const mem = await platform.getMemoryInfo();
|
|
22
|
+
expect(mem.totalMB).toBeGreaterThan(0);
|
|
23
|
+
expect(mem.freeMB).toBeGreaterThan(0);
|
|
24
|
+
expect(mem.usedPct).toBeGreaterThanOrEqual(0);
|
|
25
|
+
expect(mem.usedPct).toBeLessThanOrEqual(100);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('getDiskInfo returns at least one drive with valid data', async () => {
|
|
29
|
+
const disks = await platform.getDiskInfo();
|
|
30
|
+
expect(Array.isArray(disks)).toBe(true);
|
|
31
|
+
expect(disks.length).toBeGreaterThanOrEqual(1);
|
|
32
|
+
const first = disks[0];
|
|
33
|
+
expect(first.drive).toMatch(/^[A-Z]:$/);
|
|
34
|
+
expect(first.totalGB).toBeGreaterThan(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('getListeningPids returns array of numbers', async () => {
|
|
38
|
+
const pids = await platform.getListeningPids();
|
|
39
|
+
expect(Array.isArray(pids)).toBe(true);
|
|
40
|
+
for (const pid of pids) {
|
|
41
|
+
expect(typeof pid).toBe('number');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('getStartupItems returns an array', async () => {
|
|
46
|
+
const items = await platform.getStartupItems();
|
|
47
|
+
expect(Array.isArray(items)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('getTempDirs returns non-empty string array', () => {
|
|
51
|
+
const dirs = platform.getTempDirs();
|
|
52
|
+
expect(Array.isArray(dirs)).toBe(true);
|
|
53
|
+
expect(dirs.length).toBeGreaterThan(0);
|
|
54
|
+
for (const dir of dirs) {
|
|
55
|
+
expect(typeof dir).toBe('string');
|
|
56
|
+
expect(dir.length).toBeGreaterThan(0);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { CpuMonitorPlugin } from '../../src/plugins/cpu-monitor.js';
|
|
3
|
+
|
|
4
|
+
const describeWindows = process.platform === 'win32' ? describe : describe.skip;
|
|
5
|
+
|
|
6
|
+
describeWindows('CpuMonitorPlugin', () => {
|
|
7
|
+
let plugin: CpuMonitorPlugin;
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
plugin = new CpuMonitorPlugin();
|
|
11
|
+
plugin.configure({
|
|
12
|
+
enabled: true,
|
|
13
|
+
interval: 120000,
|
|
14
|
+
autoFix: false,
|
|
15
|
+
thresholds: { warningPct: 75, criticalPct: 90 },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('check() returns cpuUsedPct between 0 and 100', async () => {
|
|
20
|
+
const result = await plugin.check();
|
|
21
|
+
expect(['healthy', 'warning', 'critical']).toContain(result.status);
|
|
22
|
+
expect(result.metrics).toHaveProperty('cpuUsedPct');
|
|
23
|
+
expect(result.metrics.cpuUsedPct).toBeGreaterThanOrEqual(0);
|
|
24
|
+
expect(result.metrics.cpuUsedPct).toBeLessThanOrEqual(100);
|
|
25
|
+
}, 15000);
|
|
26
|
+
|
|
27
|
+
it('check() returns sampleCount as a positive number', async () => {
|
|
28
|
+
const result = await plugin.check();
|
|
29
|
+
expect(result.metrics).toHaveProperty('sampleCount');
|
|
30
|
+
expect(result.metrics.sampleCount).toBeGreaterThan(0);
|
|
31
|
+
}, 15000);
|
|
32
|
+
|
|
33
|
+
it('check() returns a non-empty message', async () => {
|
|
34
|
+
const result = await plugin.check();
|
|
35
|
+
expect(typeof result.message).toBe('string');
|
|
36
|
+
expect(result.message.length).toBeGreaterThan(0);
|
|
37
|
+
}, 15000);
|
|
38
|
+
|
|
39
|
+
it('check() returns warning/critical when criticalPct threshold is very low', async () => {
|
|
40
|
+
plugin.configure({
|
|
41
|
+
enabled: true,
|
|
42
|
+
interval: 120000,
|
|
43
|
+
autoFix: false,
|
|
44
|
+
thresholds: { warningPct: 0, criticalPct: 1 },
|
|
45
|
+
});
|
|
46
|
+
const result = await plugin.check();
|
|
47
|
+
expect(['warning', 'critical']).toContain(result.status);
|
|
48
|
+
// Reset
|
|
49
|
+
plugin.configure({
|
|
50
|
+
enabled: true,
|
|
51
|
+
interval: 120000,
|
|
52
|
+
autoFix: false,
|
|
53
|
+
thresholds: { warningPct: 75, criticalPct: 90 },
|
|
54
|
+
});
|
|
55
|
+
}, 15000);
|
|
56
|
+
|
|
57
|
+
it('fix() returns valid FixResult (alert-only)', async () => {
|
|
58
|
+
const result = await plugin.fix();
|
|
59
|
+
expect(result).toHaveProperty('actionsKept');
|
|
60
|
+
expect(result).toHaveProperty('resourcesFreed');
|
|
61
|
+
expect(result).toHaveProperty('beforeMetrics');
|
|
62
|
+
expect(result).toHaveProperty('afterMetrics');
|
|
63
|
+
expect(Array.isArray(result.actionsKept)).toBe(true);
|
|
64
|
+
expect(result.beforeMetrics).toHaveProperty('cpuUsedPct');
|
|
65
|
+
expect(result.afterMetrics).toHaveProperty('cpuUsedPct');
|
|
66
|
+
// Before and after should be identical for alert-only
|
|
67
|
+
expect(result.beforeMetrics.cpuUsedPct).toBe(result.afterMetrics.cpuUsedPct);
|
|
68
|
+
}, 15000);
|
|
69
|
+
});
|