@nocville/npc 0.2.2 → 0.2.3
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/README.md +23 -5
- package/dist/cjs/cli.js +55 -34
- package/dist/cjs/core/base-npc.js +8 -0
- package/dist/cjs/core/config.js +19 -2
- package/dist/cjs/core/errors.js +23 -0
- package/dist/cjs/core/http.js +121 -0
- package/dist/cjs/core/pixel-art.js +110 -0
- package/dist/cjs/core/polling-npc.js +99 -14
- package/dist/cjs/core/text.js +13 -0
- package/dist/cjs/core/thresholds.js +29 -0
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/npcs/agent-common/session.js +1 -0
- package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
- package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
- package/dist/cjs/npcs/github-watcher/index.js +82 -36
- package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
- package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
- package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
- package/dist/cjs/npcs/log-watcher/index.js +109 -103
- package/dist/cjs/npcs/log-watcher/tail.js +100 -53
- package/dist/cjs/npcs/process-watcher/index.js +61 -51
- package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
- package/dist/cjs/npcs/receptionist/defaults.js +4 -0
- package/dist/cjs/npcs/receptionist/index.js +45 -19
- package/dist/cjs/npcs/receptionist/weather.js +42 -47
- package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
- package/dist/cjs/npcs/system-monitor/index.js +52 -28
- package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
- package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
- package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
- package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
- package/dist/cli.js +56 -35
- package/dist/core/base-npc.d.ts +5 -0
- package/dist/core/base-npc.js +8 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +18 -2
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/http.d.ts +52 -0
- package/dist/core/http.js +111 -0
- package/dist/core/pixel-art.d.ts +38 -0
- package/dist/core/pixel-art.js +105 -0
- package/dist/core/polling-npc.d.ts +22 -1
- package/dist/core/polling-npc.js +98 -13
- package/dist/core/text.d.ts +6 -0
- package/dist/core/text.js +12 -0
- package/dist/core/thresholds.d.ts +9 -1
- package/dist/core/thresholds.js +27 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/npcs/agent-common/session.js +1 -0
- package/dist/npcs/github-watcher/defaults.d.ts +6 -0
- package/dist/npcs/github-watcher/defaults.js +7 -1
- package/dist/npcs/github-watcher/github-api.d.ts +21 -7
- package/dist/npcs/github-watcher/github-api.js +80 -45
- package/dist/npcs/github-watcher/index.d.ts +6 -0
- package/dist/npcs/github-watcher/index.js +83 -37
- package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
- package/dist/npcs/internet-connectivity/checks.js +70 -67
- package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
- package/dist/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/npcs/internet-connectivity/index.d.ts +19 -6
- package/dist/npcs/internet-connectivity/index.js +135 -133
- package/dist/npcs/log-watcher/buffer.d.ts +22 -0
- package/dist/npcs/log-watcher/buffer.js +58 -0
- package/dist/npcs/log-watcher/index.d.ts +22 -21
- package/dist/npcs/log-watcher/index.js +112 -106
- package/dist/npcs/log-watcher/tail.d.ts +23 -3
- package/dist/npcs/log-watcher/tail.js +101 -54
- package/dist/npcs/process-watcher/index.d.ts +1 -1
- package/dist/npcs/process-watcher/index.js +62 -52
- package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
- package/dist/npcs/process-watcher/process-scanner.js +61 -71
- package/dist/npcs/receptionist/defaults.d.ts +4 -0
- package/dist/npcs/receptionist/defaults.js +4 -0
- package/dist/npcs/receptionist/index.d.ts +12 -2
- package/dist/npcs/receptionist/index.js +46 -20
- package/dist/npcs/receptionist/weather.d.ts +8 -6
- package/dist/npcs/receptionist/weather.js +39 -45
- package/dist/npcs/system-monitor/collectors.d.ts +15 -3
- package/dist/npcs/system-monitor/collectors.js +64 -17
- package/dist/npcs/system-monitor/defaults.d.ts +1 -1
- package/dist/npcs/system-monitor/index.d.ts +2 -1
- package/dist/npcs/system-monitor/index.js +54 -30
- package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
- package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
- package/dist/npcs/web-page-watcher/capture.js +21 -1
- package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
- package/dist/npcs/web-page-watcher/defaults.js +41 -1
- package/dist/npcs/web-page-watcher/index.d.ts +18 -1
- package/dist/npcs/web-page-watcher/index.js +121 -70
- package/dist/npcs/web-page-watcher/pixelate.js +2 -1
- package/package.json +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PollingNPC } from '../../core/polling-npc.js';
|
|
2
|
-
import { worstStatus } from '../../core/thresholds.js';
|
|
3
|
-
import { pushHistory } from '../../core/screen.js';
|
|
2
|
+
import { evaluateThreshold, worstStatus, moodForStatus } from '../../core/thresholds.js';
|
|
3
|
+
import { statusColor, pushHistory } from '../../core/screen.js';
|
|
4
|
+
import { NpcConfigError, assertPosix } from '../../core/errors.js';
|
|
4
5
|
import { scanProcesses, detectTransitions } from './process-scanner.js';
|
|
5
6
|
import { DEFAULTS } from './defaults.js';
|
|
6
7
|
/** Max per-process panels on the companion dashboard. */
|
|
@@ -12,14 +13,17 @@ export class ProcessWatcherNPC extends PollingNPC {
|
|
|
12
13
|
cpuHistories = new Map();
|
|
13
14
|
previousRunning = null;
|
|
14
15
|
constructor(config) {
|
|
15
|
-
|
|
16
|
-
throw new Error('process-watcher is not supported on Windows (relies on the POSIX `ps` command). ' +
|
|
17
|
-
'Run it under WSL/Linux, or use system-monitor instead.');
|
|
18
|
-
}
|
|
16
|
+
assertPosix('process-watcher', 'It relies on the POSIX `ps` command; run it under WSL/Linux, or use system-monitor instead.');
|
|
19
17
|
super(config);
|
|
20
|
-
this.processes = config.processes;
|
|
18
|
+
this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
|
|
19
|
+
if (this.processes.length === 0) {
|
|
20
|
+
throw new NpcConfigError('process-watcher needs at least one process name (--processes)');
|
|
21
|
+
}
|
|
21
22
|
this.cpuWarn = config.cpuWarn ?? DEFAULTS.cpuWarn;
|
|
22
23
|
this.memWarn = config.memWarn ?? DEFAULTS.memWarn;
|
|
24
|
+
if (!(this.cpuWarn > 0) || !(this.memWarn > 0)) {
|
|
25
|
+
throw new NpcConfigError('--cpu-warn and --mem-warn must be positive numbers');
|
|
26
|
+
}
|
|
23
27
|
}
|
|
24
28
|
get name() {
|
|
25
29
|
return 'process-watcher';
|
|
@@ -30,9 +34,6 @@ export class ProcessWatcherNPC extends PollingNPC {
|
|
|
30
34
|
get archetype() {
|
|
31
35
|
return 'service';
|
|
32
36
|
}
|
|
33
|
-
get heartbeatSeconds() {
|
|
34
|
-
return 30;
|
|
35
|
-
}
|
|
36
37
|
get companion() {
|
|
37
38
|
return {
|
|
38
39
|
screenType: 'dashboard-wall',
|
|
@@ -42,22 +43,28 @@ export class ProcessWatcherNPC extends PollingNPC {
|
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
async collect() {
|
|
45
|
-
|
|
46
|
+
let infos;
|
|
47
|
+
try {
|
|
48
|
+
infos = await scanProcesses(this.processes);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
// Not knowing is different from "everything died": report unknown and
|
|
52
|
+
// leave the transition baseline alone so recovery doesn't say "is back".
|
|
53
|
+
return this.scanFailed(err);
|
|
54
|
+
}
|
|
46
55
|
const statuses = [];
|
|
47
|
-
const
|
|
56
|
+
const problems = [];
|
|
48
57
|
const metrics = {};
|
|
49
58
|
const panels = [];
|
|
50
59
|
let totalRunning = 0;
|
|
51
|
-
let totalMissing = 0;
|
|
52
60
|
for (const info of infos) {
|
|
53
61
|
const safeKey = info.name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
54
62
|
if (!info.running) {
|
|
55
63
|
statuses.push('critical');
|
|
56
|
-
|
|
64
|
+
problems.push(`${info.name} DOWN`);
|
|
57
65
|
metrics[`${safeKey}_running`] = 0;
|
|
58
66
|
metrics[`${safeKey}_cpu`] = 0;
|
|
59
67
|
metrics[`${safeKey}_mem_mb`] = 0;
|
|
60
|
-
totalMissing++;
|
|
61
68
|
if (panels.length < MAX_PANELS) {
|
|
62
69
|
panels.push({ label: info.name, value: 'DOWN', color: 'red' });
|
|
63
70
|
}
|
|
@@ -65,72 +72,75 @@ export class ProcessWatcherNPC extends PollingNPC {
|
|
|
65
72
|
}
|
|
66
73
|
totalRunning++;
|
|
67
74
|
const cpu = info.cpuPercent ?? 0;
|
|
75
|
+
const memMb = info.memMb ?? 0;
|
|
68
76
|
metrics[`${safeKey}_running`] = 1;
|
|
69
77
|
metrics[`${safeKey}_cpu`] = cpu;
|
|
70
|
-
metrics[`${safeKey}_mem_mb`] =
|
|
78
|
+
metrics[`${safeKey}_mem_mb`] = memMb;
|
|
71
79
|
const history = this.cpuHistories.get(info.name) ?? [];
|
|
72
80
|
pushHistory(history, cpu);
|
|
73
81
|
this.cpuHistories.set(info.name, history);
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
statuses.push('healthy');
|
|
87
|
-
messageParts.push(`${info.name}: OK`);
|
|
82
|
+
// Single warning thresholds: no critical level for resource use.
|
|
83
|
+
const cpuStatus = evaluateThreshold(cpu, { warning: this.cpuWarn, critical: Infinity });
|
|
84
|
+
const memStatus = evaluateThreshold(memMb, { warning: this.memWarn, critical: Infinity });
|
|
85
|
+
const status = worstStatus([cpuStatus, memStatus]);
|
|
86
|
+
statuses.push(status);
|
|
87
|
+
if (status !== 'healthy') {
|
|
88
|
+
const high = [];
|
|
89
|
+
if (cpuStatus !== 'healthy')
|
|
90
|
+
high.push(`cpu ${cpu}%`);
|
|
91
|
+
if (memStatus !== 'healthy')
|
|
92
|
+
high.push(`mem ${Math.round(memMb)}MB`);
|
|
93
|
+
problems.push(`${info.name} high ${high.join(', ')}`);
|
|
88
94
|
}
|
|
89
95
|
if (panels.length < MAX_PANELS) {
|
|
90
96
|
panels.push({
|
|
91
97
|
label: info.name,
|
|
92
98
|
value: `${cpu}%`,
|
|
93
|
-
color:
|
|
99
|
+
color: statusColor(status),
|
|
94
100
|
sparkline: [...history],
|
|
95
101
|
});
|
|
96
102
|
}
|
|
97
103
|
}
|
|
104
|
+
const totalMissing = infos.length - totalRunning;
|
|
98
105
|
metrics['total_watched'] = this.processes.length;
|
|
99
106
|
metrics['total_running'] = totalRunning;
|
|
100
107
|
metrics['total_missing'] = totalMissing;
|
|
101
108
|
// Transition speech: compare the running set to last poll's. On the first
|
|
102
109
|
// poll we only seed the set (no bogus "is back" for already-running procs).
|
|
103
110
|
const currentRunning = new Set(infos.filter((i) => i.running).map((i) => i.name));
|
|
104
|
-
|
|
105
|
-
if (this.previousRunning !== null) {
|
|
106
|
-
transitions = detectTransitions(this.previousRunning, currentRunning);
|
|
107
|
-
}
|
|
111
|
+
const transitions = this.previousRunning === null ? [] : detectTransitions(this.previousRunning, currentRunning);
|
|
108
112
|
this.previousRunning = currentRunning;
|
|
109
113
|
const status = worstStatus(statuses);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
const summary = problems.length === 0
|
|
115
|
+
? `All ${infos.length} running`
|
|
116
|
+
: `${totalRunning}/${infos.length} running · ${problems.join(' · ')}`;
|
|
117
|
+
// A death/return takes over the bubble this cycle so it reads as an alert.
|
|
118
|
+
const message = transitions.length > 0 ? transitions.join(' · ') : summary;
|
|
114
119
|
return {
|
|
115
120
|
status,
|
|
116
|
-
mood,
|
|
121
|
+
mood: moodForStatus(status),
|
|
117
122
|
message,
|
|
118
123
|
subStatus: totalMissing > 0 ? 'process-down' : undefined,
|
|
119
|
-
screenData,
|
|
124
|
+
screenData: { layout: 'multi-metric', values: panels },
|
|
120
125
|
metrics,
|
|
121
126
|
};
|
|
122
127
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
scanFailed(err) {
|
|
129
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
130
|
+
this.logger.warn('Cannot list processes', { error: reason });
|
|
131
|
+
return {
|
|
132
|
+
status: 'unknown',
|
|
133
|
+
mood: moodForStatus('unknown'),
|
|
134
|
+
message: 'Cannot list processes (ps failed)',
|
|
135
|
+
subStatus: 'scan-failed',
|
|
136
|
+
screenData: {
|
|
137
|
+
layout: 'multi-metric',
|
|
138
|
+
values: this.processes
|
|
139
|
+
.slice(0, MAX_PANELS)
|
|
140
|
+
.map((name) => ({ label: name, value: '?', color: 'gray' })),
|
|
141
|
+
},
|
|
142
|
+
metrics: { total_watched: this.processes.length },
|
|
143
|
+
};
|
|
134
144
|
}
|
|
135
145
|
}
|
|
136
146
|
//# sourceMappingURL=index.js.map
|
|
@@ -2,6 +2,8 @@ export interface ProcessInfo {
|
|
|
2
2
|
name: string;
|
|
3
3
|
running: boolean;
|
|
4
4
|
pid?: number;
|
|
5
|
+
/** Instances matched (all are summed into cpu/mem). */
|
|
6
|
+
count?: number;
|
|
5
7
|
cpuPercent?: number;
|
|
6
8
|
memMb?: number;
|
|
7
9
|
}
|
|
@@ -11,9 +13,35 @@ export interface ProcessInfo {
|
|
|
11
13
|
* "<name> is back" when one returns.
|
|
12
14
|
*/
|
|
13
15
|
export declare function detectTransitions(prev: Set<string>, curr: Set<string>): string[];
|
|
16
|
+
export interface PsEntry {
|
|
17
|
+
pid: number;
|
|
18
|
+
cpu: number;
|
|
19
|
+
memKb: number;
|
|
20
|
+
/** Executable name (Linux) or path (macOS); may contain spaces. */
|
|
21
|
+
comm: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse header-less `ps -eo pid=,pcpu=,rss=,comm=` output. COMMAND is the rest
|
|
25
|
+
* of the line, so macOS paths with spaces ("Google Chrome") survive intact.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parsePsOutput(stdout: string): PsEntry[];
|
|
28
|
+
/**
|
|
29
|
+
* Does a ps COMMAND entry match a watched name? Case-insensitive on the
|
|
30
|
+
* executable's basename:
|
|
31
|
+
* - exact: `nginx` matches `/usr/sbin/nginx`;
|
|
32
|
+
* - version suffix: `python` matches `python3` and `python3.11`;
|
|
33
|
+
* - Linux truncation: a name longer than 15 chars matches its 15-char prefix;
|
|
34
|
+
* - a name containing `/` must equal the full path (macOS reports paths).
|
|
35
|
+
* Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function matchesName(comm: string, searchName: string): boolean;
|
|
38
|
+
/** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
|
|
39
|
+
export declare function listProcesses(): Promise<PsEntry[]>;
|
|
40
|
+
/** Summarise each watched name against a process list (CPU and memory summed per name). */
|
|
41
|
+
export declare function matchProcesses(names: string[], entries: PsEntry[]): ProcessInfo[];
|
|
14
42
|
/**
|
|
15
43
|
* Scan for processes by name and return their status and resource usage.
|
|
16
|
-
* Works on macOS and Linux
|
|
44
|
+
* Works on macOS and Linux via `ps`. Throws when the process list is unavailable.
|
|
17
45
|
*/
|
|
18
46
|
export declare function scanProcesses(names: string[]): Promise<ProcessInfo[]>;
|
|
19
47
|
//# sourceMappingURL=process-scanner.d.ts.map
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const execFileAsync = promisify(execFile);
|
|
4
|
+
/** Bounds on the `ps` call: a wedged or enormous process table can't stall or flood us. */
|
|
5
|
+
const PS_TIMEOUT_MS = 5000;
|
|
6
|
+
const PS_MAX_BUFFER = 16 * 1024 * 1024;
|
|
7
|
+
/** Linux truncates `comm` to 15 characters (TASK_COMM_LEN - 1). */
|
|
8
|
+
const LINUX_COMM_LEN = 15;
|
|
4
9
|
/**
|
|
5
10
|
* Compare the previous and current running-process sets and return speech
|
|
6
11
|
* strings for any transitions: "<name> died!" when a process disappears and
|
|
@@ -19,92 +24,77 @@ export function detectTransitions(prev, curr) {
|
|
|
19
24
|
return messages;
|
|
20
25
|
}
|
|
21
26
|
/**
|
|
22
|
-
* Parse
|
|
23
|
-
*
|
|
27
|
+
* Parse header-less `ps -eo pid=,pcpu=,rss=,comm=` output. COMMAND is the rest
|
|
28
|
+
* of the line, so macOS paths with spaces ("Google Chrome") survive intact.
|
|
24
29
|
*/
|
|
25
|
-
function parsePsOutput(stdout) {
|
|
26
|
-
const lines = stdout.trim().split('\n');
|
|
27
|
-
// Skip the header line
|
|
30
|
+
export function parsePsOutput(stdout) {
|
|
28
31
|
const entries = [];
|
|
29
|
-
for (
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
32
|
+
for (const line of stdout.split('\n')) {
|
|
33
|
+
const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.+?)\s*$/);
|
|
34
|
+
if (!m)
|
|
32
35
|
continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const memKb = parseInt(parts[2], 10);
|
|
40
|
-
// COMMAND is everything from the 4th column onward
|
|
41
|
-
const comm = parts.slice(3).join(' ');
|
|
42
|
-
if (Number.isNaN(pid) || Number.isNaN(cpu) || Number.isNaN(memKb))
|
|
43
|
-
continue;
|
|
44
|
-
entries.push({ pid, cpu, memKb, comm });
|
|
36
|
+
entries.push({
|
|
37
|
+
pid: Number(m[1]),
|
|
38
|
+
cpu: Number(m[2]),
|
|
39
|
+
memKb: Number(m[3]),
|
|
40
|
+
comm: m[4],
|
|
41
|
+
});
|
|
45
42
|
}
|
|
46
43
|
return entries;
|
|
47
44
|
}
|
|
48
45
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
46
|
+
* Does a ps COMMAND entry match a watched name? Case-insensitive on the
|
|
47
|
+
* executable's basename:
|
|
48
|
+
* - exact: `nginx` matches `/usr/sbin/nginx`;
|
|
49
|
+
* - version suffix: `python` matches `python3` and `python3.11`;
|
|
50
|
+
* - Linux truncation: a name longer than 15 chars matches its 15-char prefix;
|
|
51
|
+
* - a name containing `/` must equal the full path (macOS reports paths).
|
|
52
|
+
* Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
|
|
52
53
|
*/
|
|
53
|
-
function matchesName(comm, searchName) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
export function matchesName(comm, searchName) {
|
|
55
|
+
if (searchName.includes('/'))
|
|
56
|
+
return comm === searchName;
|
|
57
|
+
const base = (comm.split('/').pop() ?? comm).toLowerCase();
|
|
58
|
+
const name = searchName.toLowerCase();
|
|
59
|
+
if (base === name)
|
|
57
60
|
return true;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
61
|
+
if (name.length > LINUX_COMM_LEN && base === name.slice(0, LINUX_COMM_LEN))
|
|
62
|
+
return true;
|
|
63
|
+
return base.startsWith(name) && /^\d[\d.]*$/.test(base.slice(name.length));
|
|
61
64
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
let entries;
|
|
77
|
-
try {
|
|
78
|
-
const { stdout } = await execFileAsync('ps', psArgs);
|
|
79
|
-
entries = parsePsOutput(stdout);
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
// If ps fails, report all processes as unknown / not running
|
|
83
|
-
return names.map((name) => ({ name, running: false }));
|
|
84
|
-
}
|
|
85
|
-
const results = [];
|
|
86
|
-
for (const name of names) {
|
|
87
|
-
// Find all matching entries for this process name
|
|
65
|
+
/** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
|
|
66
|
+
export async function listProcesses() {
|
|
67
|
+
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,pcpu=,rss=,comm='], {
|
|
68
|
+
timeout: PS_TIMEOUT_MS,
|
|
69
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
70
|
+
// Some locales print %CPU with a decimal comma ("1,5"); force "1.5".
|
|
71
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
72
|
+
});
|
|
73
|
+
return parsePsOutput(stdout);
|
|
74
|
+
}
|
|
75
|
+
/** Summarise each watched name against a process list (CPU and memory summed per name). */
|
|
76
|
+
export function matchProcesses(names, entries) {
|
|
77
|
+
return names.map((name) => {
|
|
88
78
|
const matching = entries.filter((e) => matchesName(e.comm, name));
|
|
89
|
-
if (matching.length === 0)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let totalCpu = 0;
|
|
95
|
-
let totalMemKb = 0;
|
|
96
|
-
for (const entry of matching) {
|
|
97
|
-
totalCpu += entry.cpu;
|
|
98
|
-
totalMemKb += entry.memKb;
|
|
99
|
-
}
|
|
100
|
-
results.push({
|
|
79
|
+
if (matching.length === 0)
|
|
80
|
+
return { name, running: false };
|
|
81
|
+
const totalCpu = matching.reduce((sum, e) => sum + e.cpu, 0);
|
|
82
|
+
const totalMemKb = matching.reduce((sum, e) => sum + e.memKb, 0);
|
|
83
|
+
return {
|
|
101
84
|
name,
|
|
102
85
|
running: true,
|
|
103
86
|
pid: matching[0].pid,
|
|
87
|
+
count: matching.length,
|
|
104
88
|
cpuPercent: Math.round(totalCpu * 100) / 100,
|
|
105
89
|
memMb: Math.round((totalMemKb / 1024) * 100) / 100,
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Scan for processes by name and return their status and resource usage.
|
|
95
|
+
* Works on macOS and Linux via `ps`. Throws when the process list is unavailable.
|
|
96
|
+
*/
|
|
97
|
+
export async function scanProcesses(names) {
|
|
98
|
+
return matchProcesses(names, await listProcesses());
|
|
109
99
|
}
|
|
110
100
|
//# sourceMappingURL=process-scanner.js.map
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export declare const DEFAULTS: {
|
|
2
2
|
pollInterval: number;
|
|
3
3
|
weatherRefreshMs: number;
|
|
4
|
+
/** Drop a reading this old (repeated refresh failures) rather than show stale weather. */
|
|
5
|
+
weatherMaxAgeMs: number;
|
|
6
|
+
/** Wait this long before retrying a failed geocode / IP geo / weather call. */
|
|
7
|
+
retryBackoffMs: number;
|
|
4
8
|
fetchTimeoutMs: number;
|
|
5
9
|
};
|
|
6
10
|
//# sourceMappingURL=defaults.d.ts.map
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export const DEFAULTS = {
|
|
2
2
|
pollInterval: 60000, // 60s clock tick
|
|
3
3
|
weatherRefreshMs: 15 * 60 * 1000, // refresh weather every 15 minutes
|
|
4
|
+
/** Drop a reading this old (repeated refresh failures) rather than show stale weather. */
|
|
5
|
+
weatherMaxAgeMs: 2 * 60 * 60 * 1000,
|
|
6
|
+
/** Wait this long before retrying a failed geocode / IP geo / weather call. */
|
|
7
|
+
retryBackoffMs: 5 * 60 * 1000,
|
|
4
8
|
fetchTimeoutMs: 5000,
|
|
5
9
|
};
|
|
6
10
|
//# sourceMappingURL=defaults.js.map
|
|
@@ -7,15 +7,25 @@ export interface ReceptionistConfig extends PollingNpcConfig {
|
|
|
7
7
|
location?: string;
|
|
8
8
|
/** Display temperature in Fahrenheit instead of Celsius. */
|
|
9
9
|
fahrenheit?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Fall back to IP geolocation (ipapi.co) when no --lat/--lon or --location
|
|
12
|
+
* is given. Default true; false keeps the receptionist in time-only mode
|
|
13
|
+
* rather than sending this machine's IP to a third party.
|
|
14
|
+
*/
|
|
15
|
+
ipGeo?: boolean;
|
|
10
16
|
}
|
|
11
17
|
export declare class ReceptionistNPC extends PollingNPC {
|
|
12
18
|
private explicitCoords;
|
|
13
19
|
private location?;
|
|
14
20
|
private fahrenheit;
|
|
21
|
+
private ipGeo;
|
|
15
22
|
private coords;
|
|
16
|
-
|
|
23
|
+
/** Earliest time to retry geocoding / IP geo after a failure. */
|
|
24
|
+
private nextGeoAttempt;
|
|
17
25
|
private weather;
|
|
18
|
-
private
|
|
26
|
+
private weatherFetchedAt;
|
|
27
|
+
/** Earliest time to retry the weather after a failure. */
|
|
28
|
+
private nextWeatherAttempt;
|
|
19
29
|
constructor(config: ReceptionistConfig);
|
|
20
30
|
get name(): string;
|
|
21
31
|
protected get defaultDisplayName(): string;
|
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import { PollingNPC } from '../../core/polling-npc.js';
|
|
2
|
+
import { NpcConfigError } from '../../core/errors.js';
|
|
2
3
|
import { DEFAULTS } from './defaults.js';
|
|
3
|
-
import { fetchWeather, geocodeLocation, autoGeo, formatClock, toFahrenheit } from './weather.js';
|
|
4
|
+
import { fetchWeather, geocodeLocation, autoGeo, formatClock, toFahrenheit, validCoords, } from './weather.js';
|
|
4
5
|
export class ReceptionistNPC extends PollingNPC {
|
|
5
6
|
explicitCoords;
|
|
6
7
|
location;
|
|
7
8
|
fahrenheit;
|
|
9
|
+
ipGeo;
|
|
8
10
|
coords = null;
|
|
9
|
-
|
|
11
|
+
/** Earliest time to retry geocoding / IP geo after a failure. */
|
|
12
|
+
nextGeoAttempt = 0;
|
|
10
13
|
weather = null;
|
|
11
|
-
|
|
14
|
+
weatherFetchedAt = 0;
|
|
15
|
+
/** Earliest time to retry the weather after a failure. */
|
|
16
|
+
nextWeatherAttempt = 0;
|
|
12
17
|
constructor(config) {
|
|
13
18
|
super({ ...config, pollInterval: config.pollInterval ?? DEFAULTS.pollInterval });
|
|
19
|
+
if ((config.lat === undefined) !== (config.lon === undefined)) {
|
|
20
|
+
throw new NpcConfigError('--lat and --lon must be given together');
|
|
21
|
+
}
|
|
22
|
+
if (config.lat !== undefined && !validCoords(config.lat, config.lon)) {
|
|
23
|
+
throw new NpcConfigError('--lat must be within ±90 and --lon within ±180');
|
|
24
|
+
}
|
|
14
25
|
this.explicitCoords =
|
|
15
26
|
config.lat !== undefined && config.lon !== undefined
|
|
16
27
|
? { lat: config.lat, lon: config.lon }
|
|
17
28
|
: null;
|
|
18
|
-
this.location = config.location;
|
|
29
|
+
this.location = config.location?.trim() || undefined;
|
|
19
30
|
this.fahrenheit = config.fahrenheit ?? false;
|
|
31
|
+
this.ipGeo = config.ipGeo ?? true;
|
|
20
32
|
}
|
|
21
33
|
get name() {
|
|
22
34
|
return 'receptionist';
|
|
@@ -75,41 +87,55 @@ export class ReceptionistNPC extends PollingNPC {
|
|
|
75
87
|
};
|
|
76
88
|
}
|
|
77
89
|
async refreshWeather() {
|
|
78
|
-
|
|
79
|
-
await this.resolveCoords();
|
|
80
|
-
}
|
|
90
|
+
const now = Date.now();
|
|
81
91
|
if (!this.coords)
|
|
92
|
+
await this.resolveCoords(now);
|
|
93
|
+
// Don't show a reading that has gone stale through repeated failures.
|
|
94
|
+
if (this.weather && now - this.weatherFetchedAt > DEFAULTS.weatherMaxAgeMs) {
|
|
95
|
+
this.weather = null;
|
|
96
|
+
}
|
|
97
|
+
if (!this.coords || now < this.nextWeatherAttempt)
|
|
82
98
|
return;
|
|
83
|
-
|
|
84
|
-
if (this.weather && now - this.lastWeatherFetch < DEFAULTS.weatherRefreshMs)
|
|
99
|
+
if (this.weather && now - this.weatherFetchedAt < DEFAULTS.weatherRefreshMs)
|
|
85
100
|
return;
|
|
86
101
|
const w = await fetchWeather(this.coords, DEFAULTS.fetchTimeoutMs);
|
|
87
102
|
if (w) {
|
|
88
103
|
this.weather = w;
|
|
89
|
-
this.
|
|
104
|
+
this.weatherFetchedAt = now;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
this.nextWeatherAttempt = now + DEFAULTS.retryBackoffMs;
|
|
90
108
|
}
|
|
91
109
|
}
|
|
92
|
-
async resolveCoords() {
|
|
110
|
+
async resolveCoords(now) {
|
|
93
111
|
if (this.explicitCoords) {
|
|
94
112
|
this.coords = this.explicitCoords;
|
|
95
113
|
return;
|
|
96
114
|
}
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
if (this.geoAttempted)
|
|
115
|
+
// Back off after a failure rather than hitting the keyless endpoints every tick.
|
|
116
|
+
if (now < this.nextGeoAttempt)
|
|
100
117
|
return;
|
|
101
|
-
this.
|
|
118
|
+
this.nextGeoAttempt = now + DEFAULTS.retryBackoffMs;
|
|
102
119
|
if (this.location) {
|
|
103
120
|
this.coords = await geocodeLocation(this.location, DEFAULTS.fetchTimeoutMs);
|
|
104
121
|
if (this.coords)
|
|
105
122
|
return;
|
|
106
|
-
this.logger.warn(`Could not geocode "${this.location}", falling back to IP geo
|
|
123
|
+
this.logger.warn(`Could not geocode "${this.location}"` + (this.ipGeo ? ', falling back to IP geo' : ''));
|
|
124
|
+
}
|
|
125
|
+
if (!this.ipGeo) {
|
|
126
|
+
if (!this.location) {
|
|
127
|
+
this.nextGeoAttempt = Infinity; // nothing to resolve, ever
|
|
128
|
+
this.logger.info('No location set and IP geolocation disabled: time-only mode');
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
107
131
|
}
|
|
108
132
|
this.coords = await autoGeo(DEFAULTS.fetchTimeoutMs);
|
|
109
|
-
if (
|
|
110
|
-
this.logger.
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
if (this.coords) {
|
|
134
|
+
this.logger.info("Weather location estimated from this machine's IP via ipapi.co " +
|
|
135
|
+
'(set --location or --lat/--lon, or pass --no-ip-geo to disable)');
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.logger.warn('Weather geo unavailable — running in time-only mode for now');
|
|
113
139
|
}
|
|
114
140
|
}
|
|
115
141
|
}
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keyless weather + geolocation via open-meteo, with ipapi.co IP fallback.
|
|
3
|
-
* All network helpers return null on failure so the receptionist can degrade
|
|
4
|
-
* to a time-only mode without erroring.
|
|
5
|
-
*/
|
|
6
1
|
export interface Coords {
|
|
7
2
|
lat: number;
|
|
8
3
|
lon: number;
|
|
@@ -20,9 +15,16 @@ export declare function describeWmo(code: number): {
|
|
|
20
15
|
text: string;
|
|
21
16
|
icon: string;
|
|
22
17
|
};
|
|
18
|
+
/** A finite, in-range latitude/longitude pair. */
|
|
19
|
+
export declare function validCoords(lat: unknown, lon: unknown): boolean;
|
|
23
20
|
/** Resolve a place name to coordinates via the open-meteo geocoding API. */
|
|
24
21
|
export declare function geocodeLocation(name: string, timeoutMs?: number): Promise<Coords | null>;
|
|
25
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Best-effort IP geolocation via ipapi.co (keyless). This sends the machine's
|
|
24
|
+
* public IP to a third party, so the receptionist only calls it when no place
|
|
25
|
+
* was configured and the user hasn't opted out. Only the coordinates are used;
|
|
26
|
+
* nothing from the response is pushed to Nocville.
|
|
27
|
+
*/
|
|
26
28
|
export declare function autoGeo(timeoutMs?: number): Promise<Coords | null>;
|
|
27
29
|
/** Fetch current temperature, weather code, and wind for coordinates. */
|
|
28
30
|
export declare function fetchWeather(coords: Coords, timeoutMs?: number): Promise<Weather | null>;
|