@nocville/npc 0.2.1 → 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/LICENSE +21 -0
- package/README.md +58 -120
- 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/session-filter.js +3 -1
- 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/session-filter.d.ts +2 -2
- package/dist/core/session-filter.js +3 -1
- 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 -7
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Keyless weather + geolocation via open-meteo, with ipapi.co IP
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Keyless weather + geolocation via open-meteo, with an optional ipapi.co IP
|
|
3
|
+
* fallback. Every call is time- and size-bounded, validates the (untrusted)
|
|
4
|
+
* response, and returns null on failure so the receptionist can degrade to a
|
|
5
|
+
* time-only mode without erroring.
|
|
5
6
|
*/
|
|
7
|
+
import { asRecord, fetchJson, finiteNumber } from '../../core/http.js';
|
|
8
|
+
import { sanitizeText } from '../../core/text.js';
|
|
6
9
|
// WMO weather-interpretation codes → human text + a glyph keyword the
|
|
7
10
|
// welcome-sign renderer understands (matched by /sun|clear|partly|rain|snow|storm|fog/).
|
|
8
11
|
const WMO = {
|
|
@@ -39,65 +42,56 @@ const WMO = {
|
|
|
39
42
|
export function describeWmo(code) {
|
|
40
43
|
return WMO[code] ?? { text: 'Unknown', icon: 'cloud' };
|
|
41
44
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
signal: controller.signal,
|
|
48
|
-
headers: { 'User-Agent': 'nocville-npc' },
|
|
49
|
-
});
|
|
50
|
-
if (!res.ok)
|
|
51
|
-
return null;
|
|
52
|
-
return (await res.json());
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
finally {
|
|
58
|
-
clearTimeout(timer);
|
|
59
|
-
}
|
|
45
|
+
/** A finite, in-range latitude/longitude pair. */
|
|
46
|
+
export function validCoords(lat, lon) {
|
|
47
|
+
const la = finiteNumber(lat);
|
|
48
|
+
const lo = finiteNumber(lon);
|
|
49
|
+
return la !== null && lo !== null && Math.abs(la) <= 90 && Math.abs(lo) <= 180;
|
|
60
50
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1`;
|
|
64
|
-
const res = await fetchJson(url, timeoutMs);
|
|
65
|
-
const first = res?.['results']?.[0];
|
|
66
|
-
if (!first || typeof first['latitude'] !== 'number')
|
|
51
|
+
function coordsFrom(rec, labelKey) {
|
|
52
|
+
if (!validCoords(rec['latitude'], rec['longitude']))
|
|
67
53
|
return null;
|
|
54
|
+
const label = rec[labelKey];
|
|
68
55
|
return {
|
|
69
|
-
lat:
|
|
70
|
-
lon:
|
|
71
|
-
label:
|
|
56
|
+
lat: rec['latitude'],
|
|
57
|
+
lon: rec['longitude'],
|
|
58
|
+
label: typeof label === 'string' ? sanitizeText(label, 60) : undefined,
|
|
72
59
|
};
|
|
73
60
|
}
|
|
74
|
-
/**
|
|
61
|
+
/** Resolve a place name to coordinates via the open-meteo geocoding API. */
|
|
62
|
+
export async function geocodeLocation(name, timeoutMs = 5000) {
|
|
63
|
+
const url = 'https://geocoding-api.open-meteo.com/v1/search' +
|
|
64
|
+
`?name=${encodeURIComponent(name.slice(0, 100))}&count=1`;
|
|
65
|
+
const results = asRecord(await fetchJson(url, { timeoutMs }))?.['results'];
|
|
66
|
+
const first = Array.isArray(results) ? asRecord(results[0]) : null;
|
|
67
|
+
return first ? coordsFrom(first, 'name') : null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Best-effort IP geolocation via ipapi.co (keyless). This sends the machine's
|
|
71
|
+
* public IP to a third party, so the receptionist only calls it when no place
|
|
72
|
+
* was configured and the user hasn't opted out. Only the coordinates are used;
|
|
73
|
+
* nothing from the response is pushed to Nocville.
|
|
74
|
+
*/
|
|
75
75
|
export async function autoGeo(timeoutMs = 5000) {
|
|
76
|
-
const res = await fetchJson('https://ipapi.co/json/', timeoutMs);
|
|
77
|
-
|
|
78
|
-
return null;
|
|
79
|
-
return {
|
|
80
|
-
lat: res['latitude'],
|
|
81
|
-
lon: res['longitude'],
|
|
82
|
-
label: res['city'],
|
|
83
|
-
};
|
|
76
|
+
const res = asRecord(await fetchJson('https://ipapi.co/json/', { timeoutMs }));
|
|
77
|
+
return res ? coordsFrom(res, 'city') : null;
|
|
84
78
|
}
|
|
85
79
|
/** Fetch current temperature, weather code, and wind for coordinates. */
|
|
86
80
|
export async function fetchWeather(coords, timeoutMs = 5000) {
|
|
87
81
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}` +
|
|
88
82
|
`&longitude=${coords.lon}¤t=temperature_2m,weather_code,wind_speed_10m`;
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
if (!cur ||
|
|
83
|
+
const cur = asRecord(asRecord(await fetchJson(url, { timeoutMs }))?.['current']);
|
|
84
|
+
const temp = finiteNumber(cur?.['temperature_2m']);
|
|
85
|
+
if (!cur || temp === null || Math.abs(temp) > 100)
|
|
92
86
|
return null;
|
|
93
|
-
const code =
|
|
87
|
+
const code = finiteNumber(cur['weather_code']) ?? -1;
|
|
94
88
|
const { text, icon } = describeWmo(code);
|
|
95
89
|
return {
|
|
96
|
-
tempC: Math.round(
|
|
90
|
+
tempC: Math.round(temp),
|
|
97
91
|
code,
|
|
98
92
|
text,
|
|
99
93
|
icon,
|
|
100
|
-
windKph: Math.round(
|
|
94
|
+
windKph: Math.round(Math.max(0, finiteNumber(cur['wind_speed_10m']) ?? 0)),
|
|
101
95
|
};
|
|
102
96
|
}
|
|
103
97
|
/** Convert Celsius to Fahrenheit, rounded. */
|
|
@@ -34,11 +34,23 @@ export declare function createCpuSampler(readCpus?: () => Array<{
|
|
|
34
34
|
times: CpuTimes;
|
|
35
35
|
}>, readLoad?: () => number): CpuSampler;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* Parse `vm_stat` output into reclaimable ("available") bytes: free +
|
|
38
|
+
* inactive + speculative pages, the same estimate psutil uses on macOS.
|
|
39
|
+
* Returns null when the output is unrecognised.
|
|
38
40
|
*/
|
|
39
|
-
export declare function
|
|
41
|
+
export declare function parseVmStat(stdout: string): number | null;
|
|
40
42
|
/**
|
|
41
|
-
*
|
|
43
|
+
* Bytes of memory available to new work. On macOS `os.freemem()` counts only
|
|
44
|
+
* never-used pages (the file cache is "used"), which reads ~95% full on any
|
|
45
|
+
* healthy Mac, so the reclaimable estimate comes from `vm_stat` instead. Linux
|
|
46
|
+
* (`MemAvailable`) and Windows report a meaningful value through Node.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readAvailableMemory(): Promise<number>;
|
|
49
|
+
/** Memory usage from the total and the available byte count. */
|
|
50
|
+
export declare function collectMemory(availableBytes?: number, totalBytes?: number): MemMetrics;
|
|
51
|
+
/**
|
|
52
|
+
* Disk usage via fs.statfs, computed like `df`: used / (used + available to
|
|
53
|
+
* unprivileged users), so blocks reserved for root don't hide a full disk.
|
|
42
54
|
*/
|
|
43
55
|
export declare function collectDisk(path: string): Promise<DiskMetrics>;
|
|
44
56
|
export {};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import { promises as fsp } from 'node:fs';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
3
6
|
function aggregate(cpus) {
|
|
4
7
|
let idle = 0;
|
|
5
8
|
let total = 0;
|
|
@@ -10,6 +13,12 @@ function aggregate(cpus) {
|
|
|
10
13
|
}
|
|
11
14
|
return { idle, total };
|
|
12
15
|
}
|
|
16
|
+
function gb(bytes) {
|
|
17
|
+
return Math.round((bytes / 1024 ** 3) * 100) / 100;
|
|
18
|
+
}
|
|
19
|
+
function percentOf(part, whole) {
|
|
20
|
+
return whole > 0 ? Math.min(100, Math.max(0, Math.round((part / whole) * 100))) : 0;
|
|
21
|
+
}
|
|
13
22
|
/**
|
|
14
23
|
* Stateful CPU sampler that computes usage from the delta between consecutive
|
|
15
24
|
* `os.cpus()` snapshots — no blocking sleep. The first sample reports usage
|
|
@@ -27,34 +36,72 @@ export function createCpuSampler(readCpus = () => os.cpus(), readLoad = () => os
|
|
|
27
36
|
const idleDiff = cur.idle - base.idle;
|
|
28
37
|
const totalDiff = cur.total - base.total;
|
|
29
38
|
prev = cur;
|
|
30
|
-
const percent = totalDiff > 0 ?
|
|
39
|
+
const percent = totalDiff > 0 ? percentOf(totalDiff - idleDiff, totalDiff) : 0;
|
|
31
40
|
return { percent, loadAvg1m };
|
|
32
41
|
},
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
/**
|
|
36
|
-
*
|
|
45
|
+
* Parse `vm_stat` output into reclaimable ("available") bytes: free +
|
|
46
|
+
* inactive + speculative pages, the same estimate psutil uses on macOS.
|
|
47
|
+
* Returns null when the output is unrecognised.
|
|
48
|
+
*/
|
|
49
|
+
export function parseVmStat(stdout) {
|
|
50
|
+
const pageSize = Number(stdout.match(/page size of (\d+) bytes/)?.[1]);
|
|
51
|
+
if (!Number.isFinite(pageSize) || pageSize <= 0)
|
|
52
|
+
return null;
|
|
53
|
+
const pages = (label) => {
|
|
54
|
+
const m = stdout.match(new RegExp(`^Pages ${label}:\\s+(\\d+)`, 'm'));
|
|
55
|
+
return m ? Number(m[1]) : null;
|
|
56
|
+
};
|
|
57
|
+
const free = pages('free');
|
|
58
|
+
const inactive = pages('inactive');
|
|
59
|
+
if (free === null || inactive === null)
|
|
60
|
+
return null;
|
|
61
|
+
return (free + inactive + (pages('speculative') ?? 0)) * pageSize;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Bytes of memory available to new work. On macOS `os.freemem()` counts only
|
|
65
|
+
* never-used pages (the file cache is "used"), which reads ~95% full on any
|
|
66
|
+
* healthy Mac, so the reclaimable estimate comes from `vm_stat` instead. Linux
|
|
67
|
+
* (`MemAvailable`) and Windows report a meaningful value through Node.
|
|
37
68
|
*/
|
|
38
|
-
export function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
export async function readAvailableMemory() {
|
|
70
|
+
if (process.platform === 'darwin') {
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execFileAsync('vm_stat', [], { timeout: 2000 });
|
|
73
|
+
const available = parseVmStat(stdout);
|
|
74
|
+
if (available !== null)
|
|
75
|
+
return Math.min(available, os.totalmem());
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Fall through to Node's figure.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return os.freemem();
|
|
82
|
+
}
|
|
83
|
+
/** Memory usage from the total and the available byte count. */
|
|
84
|
+
export function collectMemory(availableBytes = os.freemem(), totalBytes = os.totalmem()) {
|
|
85
|
+
const usedBytes = Math.max(0, totalBytes - availableBytes);
|
|
86
|
+
return {
|
|
87
|
+
percent: percentOf(usedBytes, totalBytes),
|
|
88
|
+
usedGb: gb(usedBytes),
|
|
89
|
+
totalGb: gb(totalBytes),
|
|
90
|
+
};
|
|
46
91
|
}
|
|
47
92
|
/**
|
|
48
|
-
*
|
|
93
|
+
* Disk usage via fs.statfs, computed like `df`: used / (used + available to
|
|
94
|
+
* unprivileged users), so blocks reserved for root don't hide a full disk.
|
|
49
95
|
*/
|
|
50
96
|
export async function collectDisk(path) {
|
|
51
97
|
const stats = await fsp.statfs(path);
|
|
52
98
|
const totalBytes = stats.blocks * stats.bsize;
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
99
|
+
const usedBytes = (stats.blocks - stats.bfree) * stats.bsize;
|
|
100
|
+
const availBytes = stats.bavail * stats.bsize;
|
|
101
|
+
return {
|
|
102
|
+
percent: percentOf(usedBytes, usedBytes + availBytes),
|
|
103
|
+
usedGb: gb(usedBytes),
|
|
104
|
+
totalGb: gb(totalBytes),
|
|
105
|
+
};
|
|
59
106
|
}
|
|
60
107
|
//# sourceMappingURL=collectors.js.map
|
|
@@ -3,5 +3,5 @@ export declare const DEFAULTS: {
|
|
|
3
3
|
pollInterval: number;
|
|
4
4
|
diskPath: string;
|
|
5
5
|
};
|
|
6
|
-
export declare const DEFAULT_THRESHOLDS: Record<
|
|
6
|
+
export declare const DEFAULT_THRESHOLDS: Record<'cpu' | 'mem' | 'disk', ThresholdConfig>;
|
|
7
7
|
//# sourceMappingURL=defaults.d.ts.map
|
|
@@ -15,13 +15,14 @@ export declare class SystemMonitorNPC extends PollingNPC {
|
|
|
15
15
|
private cpuSampler;
|
|
16
16
|
private cpuHistory;
|
|
17
17
|
private memHistory;
|
|
18
|
+
private diskErrorLogged;
|
|
18
19
|
constructor(config: SystemMonitorConfig);
|
|
19
20
|
get name(): string;
|
|
20
21
|
protected get defaultDisplayName(): string;
|
|
21
22
|
get archetype(): Archetype;
|
|
22
|
-
get heartbeatSeconds(): number;
|
|
23
23
|
get behavior(): BehaviorHints;
|
|
24
24
|
get companion(): CompanionDecl;
|
|
25
25
|
protected collect(): Promise<CollectedMetrics>;
|
|
26
|
+
private readDisk;
|
|
26
27
|
}
|
|
27
28
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { PollingNPC } from '../../core/polling-npc.js';
|
|
2
|
-
import { evaluateThreshold, worstStatus } from '../../core/thresholds.js';
|
|
2
|
+
import { evaluateThreshold, worstStatus, moodForStatus, thresholdProblem, } from '../../core/thresholds.js';
|
|
3
3
|
import { statusColor, pushHistory } from '../../core/screen.js';
|
|
4
|
-
import {
|
|
4
|
+
import { NpcConfigError } from '../../core/errors.js';
|
|
5
|
+
import { createCpuSampler, collectMemory, collectDisk, readAvailableMemory } from './collectors.js';
|
|
5
6
|
import { DEFAULTS, DEFAULT_THRESHOLDS } from './defaults.js';
|
|
6
7
|
export class SystemMonitorNPC extends PollingNPC {
|
|
7
8
|
diskPath;
|
|
@@ -11,15 +12,21 @@ export class SystemMonitorNPC extends PollingNPC {
|
|
|
11
12
|
cpuSampler = createCpuSampler();
|
|
12
13
|
cpuHistory = [];
|
|
13
14
|
memHistory = [];
|
|
15
|
+
diskErrorLogged = false;
|
|
14
16
|
constructor(config) {
|
|
15
17
|
super({
|
|
16
18
|
...config,
|
|
17
19
|
pollInterval: config.pollInterval ?? DEFAULTS.pollInterval,
|
|
18
20
|
});
|
|
19
21
|
this.diskPath = config.diskPath ?? DEFAULTS.diskPath;
|
|
20
|
-
this.cpuThreshold = config.cpuThreshold ?? DEFAULT_THRESHOLDS
|
|
21
|
-
this.memThreshold = config.memThreshold ?? DEFAULT_THRESHOLDS
|
|
22
|
-
this.diskThreshold = config.diskThreshold ?? DEFAULT_THRESHOLDS
|
|
22
|
+
this.cpuThreshold = config.cpuThreshold ?? DEFAULT_THRESHOLDS.cpu;
|
|
23
|
+
this.memThreshold = config.memThreshold ?? DEFAULT_THRESHOLDS.mem;
|
|
24
|
+
this.diskThreshold = config.diskThreshold ?? DEFAULT_THRESHOLDS.disk;
|
|
25
|
+
const problem = thresholdProblem('CPU', this.cpuThreshold) ??
|
|
26
|
+
thresholdProblem('Memory', this.memThreshold) ??
|
|
27
|
+
thresholdProblem('Disk', this.diskThreshold);
|
|
28
|
+
if (problem)
|
|
29
|
+
throw new NpcConfigError(problem);
|
|
23
30
|
}
|
|
24
31
|
get name() {
|
|
25
32
|
return 'system-monitor';
|
|
@@ -30,9 +37,6 @@ export class SystemMonitorNPC extends PollingNPC {
|
|
|
30
37
|
get archetype() {
|
|
31
38
|
return 'service';
|
|
32
39
|
}
|
|
33
|
-
get heartbeatSeconds() {
|
|
34
|
-
return 30;
|
|
35
|
-
}
|
|
36
40
|
get behavior() {
|
|
37
41
|
return { idle: 'wander' };
|
|
38
42
|
}
|
|
@@ -46,19 +50,20 @@ export class SystemMonitorNPC extends PollingNPC {
|
|
|
46
50
|
}
|
|
47
51
|
async collect() {
|
|
48
52
|
const cpu = this.cpuSampler.sample();
|
|
49
|
-
const mem = collectMemory();
|
|
50
|
-
const disk = await
|
|
53
|
+
const mem = collectMemory(await readAvailableMemory());
|
|
54
|
+
const disk = await this.readDisk();
|
|
51
55
|
const cpuStatus = evaluateThreshold(cpu.percent, this.cpuThreshold);
|
|
52
56
|
const memStatus = evaluateThreshold(mem.percent, this.memThreshold);
|
|
53
|
-
|
|
57
|
+
// An unreadable disk (unmounted volume, bad --disk-path) is a warning, not a crash.
|
|
58
|
+
const diskStatus = disk
|
|
59
|
+
? evaluateThreshold(disk.percent, this.diskThreshold)
|
|
60
|
+
: 'warning';
|
|
54
61
|
const status = worstStatus([cpuStatus, memStatus, diskStatus]);
|
|
55
|
-
const mood = status === 'critical'
|
|
56
|
-
? 'stressed'
|
|
57
|
-
: status === 'warning'
|
|
58
|
-
? 'concerned'
|
|
59
|
-
: 'happy';
|
|
60
62
|
pushHistory(this.cpuHistory, cpu.percent);
|
|
61
63
|
pushHistory(this.memHistory, mem.percent);
|
|
64
|
+
const diskText = disk ? `${disk.percent}%` : 'n/a';
|
|
65
|
+
// Windows has no load average (os.loadavg() is always zeros there).
|
|
66
|
+
const loadText = process.platform === 'win32' ? 'n/a' : cpu.loadAvg1m.toFixed(2);
|
|
62
67
|
const screenData = {
|
|
63
68
|
layout: 'multi-metric',
|
|
64
69
|
values: [
|
|
@@ -74,28 +79,47 @@ export class SystemMonitorNPC extends PollingNPC {
|
|
|
74
79
|
color: statusColor(memStatus),
|
|
75
80
|
sparkline: [...this.memHistory],
|
|
76
81
|
},
|
|
77
|
-
{ label: 'DISK', value:
|
|
78
|
-
{ label: 'LOAD', value:
|
|
82
|
+
{ label: 'DISK', value: diskText, color: statusColor(diskStatus) },
|
|
83
|
+
{ label: 'LOAD', value: loadText, color: 'gray' },
|
|
79
84
|
],
|
|
80
85
|
};
|
|
86
|
+
const metrics = {
|
|
87
|
+
cpu_percent: cpu.percent,
|
|
88
|
+
mem_percent: mem.percent,
|
|
89
|
+
mem_used_gb: mem.usedGb,
|
|
90
|
+
mem_total_gb: mem.totalGb,
|
|
91
|
+
load_avg_1m: cpu.loadAvg1m,
|
|
92
|
+
};
|
|
93
|
+
if (disk) {
|
|
94
|
+
metrics['disk_percent'] = disk.percent;
|
|
95
|
+
metrics['disk_used_gb'] = disk.usedGb;
|
|
96
|
+
metrics['disk_total_gb'] = disk.totalGb;
|
|
97
|
+
}
|
|
81
98
|
return {
|
|
82
99
|
status,
|
|
83
|
-
mood,
|
|
84
|
-
message: `CPU: ${cpu.percent}% | Mem: ${mem.percent}% | Disk: ${disk
|
|
100
|
+
mood: moodForStatus(status),
|
|
101
|
+
message: `CPU: ${cpu.percent}% | Mem: ${mem.percent}% | Disk: ${disk ? diskText : 'unavailable'}`,
|
|
85
102
|
screenData,
|
|
86
103
|
// When strained, go find a desk and get to work; otherwise wander.
|
|
87
104
|
behavior: status !== 'healthy' ? { busy: 'seek-desk' } : undefined,
|
|
88
|
-
metrics
|
|
89
|
-
cpu_percent: cpu.percent,
|
|
90
|
-
mem_percent: mem.percent,
|
|
91
|
-
mem_used_gb: mem.usedGb,
|
|
92
|
-
mem_total_gb: mem.totalGb,
|
|
93
|
-
disk_percent: disk.percent,
|
|
94
|
-
disk_used_gb: disk.usedGb,
|
|
95
|
-
disk_total_gb: disk.totalGb,
|
|
96
|
-
load_avg_1m: cpu.loadAvg1m,
|
|
97
|
-
},
|
|
105
|
+
metrics,
|
|
98
106
|
};
|
|
99
107
|
}
|
|
108
|
+
async readDisk() {
|
|
109
|
+
try {
|
|
110
|
+
const disk = await collectDisk(this.diskPath);
|
|
111
|
+
this.diskErrorLogged = false;
|
|
112
|
+
return disk;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (!this.diskErrorLogged) {
|
|
116
|
+
this.diskErrorLogged = true;
|
|
117
|
+
this.logger.warn(`Cannot read disk usage for ${this.diskPath}`, {
|
|
118
|
+
error: err instanceof Error ? err.message : String(err),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
100
124
|
}
|
|
101
125
|
//# sourceMappingURL=index.js.map
|
|
@@ -5,12 +5,12 @@ export interface BasicCheck {
|
|
|
5
5
|
ok: boolean;
|
|
6
6
|
timedOut: boolean;
|
|
7
7
|
}
|
|
8
|
-
/** Extract the <title> text from
|
|
8
|
+
/** Extract the <title> text from (untrusted) HTML: entities decoded, sanitized, ≤80 chars. */
|
|
9
9
|
export declare function extractTitle(html: string): string;
|
|
10
10
|
/** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
|
|
11
11
|
export declare function statusFromHttp(code: number, timedOut?: boolean): Status;
|
|
12
12
|
/** Fetch a page and extract HTTP status + <title> (basic, no browser). */
|
|
13
13
|
export declare function basicCheck(url: string, timeoutMs?: number): Promise<BasicCheck>;
|
|
14
|
-
/**
|
|
14
|
+
/** Hostname of a URL for labels and service ids (never credentials or path). */
|
|
15
15
|
export declare function hostnameOf(url: string): string;
|
|
16
16
|
//# sourceMappingURL=basic-mode.d.ts.map
|
|
@@ -1,7 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
import { NPC_USER_AGENT, readTextCapped } from '../../core/http.js';
|
|
2
|
+
import { sanitizeText } from '../../core/text.js';
|
|
3
|
+
/** Bytes of HTML read to find the <title> (it lives in <head>). */
|
|
4
|
+
const MAX_HTML_BYTES = 512 * 1024;
|
|
5
|
+
const ENTITIES = {
|
|
6
|
+
amp: '&',
|
|
7
|
+
lt: '<',
|
|
8
|
+
gt: '>',
|
|
9
|
+
quot: '"',
|
|
10
|
+
apos: "'",
|
|
11
|
+
nbsp: ' ',
|
|
12
|
+
};
|
|
13
|
+
function decodeEntities(s) {
|
|
14
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
15
|
+
if (e[0] === '#') {
|
|
16
|
+
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
17
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
|
|
18
|
+
}
|
|
19
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Extract the <title> text from (untrusted) HTML: entities decoded, sanitized, ≤80 chars. */
|
|
2
23
|
export function extractTitle(html) {
|
|
3
24
|
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
4
|
-
return m ? m[1]
|
|
25
|
+
return m ? sanitizeText(decodeEntities(m[1]), 80) : '';
|
|
5
26
|
}
|
|
6
27
|
/** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
|
|
7
28
|
export function statusFromHttp(code, timedOut = false) {
|
|
@@ -15,17 +36,15 @@ export function statusFromHttp(code, timedOut = false) {
|
|
|
15
36
|
}
|
|
16
37
|
/** Fetch a page and extract HTTP status + <title> (basic, no browser). */
|
|
17
38
|
export async function basicCheck(url, timeoutMs = 8000) {
|
|
18
|
-
const controller = new AbortController();
|
|
19
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
20
39
|
try {
|
|
21
40
|
const res = await fetch(url, {
|
|
22
|
-
signal:
|
|
23
|
-
headers: { 'User-Agent':
|
|
41
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
42
|
+
headers: { 'User-Agent': NPC_USER_AGENT },
|
|
24
43
|
redirect: 'follow',
|
|
25
44
|
});
|
|
26
45
|
let title = '';
|
|
27
46
|
try {
|
|
28
|
-
title = extractTitle(await res.text
|
|
47
|
+
title = extractTitle((await readTextCapped(res, MAX_HTML_BYTES)).text);
|
|
29
48
|
}
|
|
30
49
|
catch {
|
|
31
50
|
// body unavailable; leave title blank
|
|
@@ -33,20 +52,17 @@ export async function basicCheck(url, timeoutMs = 8000) {
|
|
|
33
52
|
return { status: res.status, title, ok: res.ok, timedOut: false };
|
|
34
53
|
}
|
|
35
54
|
catch (err) {
|
|
36
|
-
const timedOut = err instanceof Error && err.name === 'AbortError';
|
|
55
|
+
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
37
56
|
return { status: 0, title: '', ok: false, timedOut };
|
|
38
57
|
}
|
|
39
|
-
finally {
|
|
40
|
-
clearTimeout(timer);
|
|
41
|
-
}
|
|
42
58
|
}
|
|
43
|
-
/**
|
|
59
|
+
/** Hostname of a URL for labels and service ids (never credentials or path). */
|
|
44
60
|
export function hostnameOf(url) {
|
|
45
61
|
try {
|
|
46
|
-
return new URL(url).hostname;
|
|
62
|
+
return new URL(url).hostname || 'page';
|
|
47
63
|
}
|
|
48
64
|
catch {
|
|
49
|
-
return url
|
|
65
|
+
return 'invalid-url';
|
|
50
66
|
}
|
|
51
67
|
}
|
|
52
68
|
//# sourceMappingURL=basic-mode.js.map
|
|
@@ -17,6 +17,10 @@ export interface PuppeteerPage {
|
|
|
17
17
|
fullPage?: boolean;
|
|
18
18
|
}): Promise<Buffer | Uint8Array>;
|
|
19
19
|
close(): Promise<void>;
|
|
20
|
+
/** Used to dismiss JS dialogs, which would otherwise stall the capture. */
|
|
21
|
+
on?(event: 'dialog', cb: (dialog: {
|
|
22
|
+
dismiss(): Promise<void>;
|
|
23
|
+
}) => void): unknown;
|
|
20
24
|
}
|
|
21
25
|
export interface PuppeteerBrowser {
|
|
22
26
|
newPage(): Promise<PuppeteerPage>;
|
|
@@ -38,6 +42,20 @@ export interface PuppeteerModule {
|
|
|
38
42
|
* platform-specific known locations (mac app bundles; `which` on linux).
|
|
39
43
|
*/
|
|
40
44
|
export declare function findChrome(explicit?: string): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Chrome launch flags. The watched page is untrusted content, so Chrome's
|
|
47
|
+
* sandbox stays on unless it can't work: as root on Linux (Chrome refuses to
|
|
48
|
+
* start sandboxed, typical in containers) or when explicitly disabled.
|
|
49
|
+
*/
|
|
50
|
+
export declare function chromeArgs(opts: {
|
|
51
|
+
sandbox: boolean;
|
|
52
|
+
isRoot: boolean;
|
|
53
|
+
}): {
|
|
54
|
+
args: string[];
|
|
55
|
+
sandboxed: boolean;
|
|
56
|
+
};
|
|
57
|
+
/** True when this process runs as root (POSIX only). */
|
|
58
|
+
export declare function runningAsRoot(): boolean;
|
|
41
59
|
/**
|
|
42
60
|
* Load the optional `puppeteer-core` peer dependency without a static import,
|
|
43
61
|
* so the package builds and runs when it isn't installed. Returns null if the
|
|
@@ -16,7 +16,11 @@ const LINUX_BINARIES = [
|
|
|
16
16
|
];
|
|
17
17
|
function which(bin) {
|
|
18
18
|
try {
|
|
19
|
-
const out = execFileSync('which', [bin], {
|
|
19
|
+
const out = execFileSync('which', [bin], {
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
timeout: 2000,
|
|
22
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
23
|
+
}).trim();
|
|
20
24
|
return out || null;
|
|
21
25
|
}
|
|
22
26
|
catch {
|
|
@@ -49,6 +53,22 @@ export function findChrome(explicit) {
|
|
|
49
53
|
}
|
|
50
54
|
return null;
|
|
51
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Chrome launch flags. The watched page is untrusted content, so Chrome's
|
|
58
|
+
* sandbox stays on unless it can't work: as root on Linux (Chrome refuses to
|
|
59
|
+
* start sandboxed, typical in containers) or when explicitly disabled.
|
|
60
|
+
*/
|
|
61
|
+
export function chromeArgs(opts) {
|
|
62
|
+
const args = ['--disable-gpu', '--hide-scrollbars', '--mute-audio', '--no-first-run'];
|
|
63
|
+
const sandboxed = opts.sandbox && !opts.isRoot;
|
|
64
|
+
if (!sandboxed)
|
|
65
|
+
args.push('--no-sandbox', '--disable-setuid-sandbox');
|
|
66
|
+
return { args, sandboxed };
|
|
67
|
+
}
|
|
68
|
+
/** True when this process runs as root (POSIX only). */
|
|
69
|
+
export function runningAsRoot() {
|
|
70
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
71
|
+
}
|
|
52
72
|
/**
|
|
53
73
|
* Load the optional `puppeteer-core` peer dependency without a static import,
|
|
54
74
|
* so the package builds and runs when it isn't installed. Returns null if the
|
|
@@ -2,6 +2,10 @@ export declare const DEFAULTS: {
|
|
|
2
2
|
pollInterval: number;
|
|
3
3
|
minPollInterval: number;
|
|
4
4
|
navTimeoutMs: number;
|
|
5
|
+
/** Extra time for screenshot + encode before a capture counts as hung. */
|
|
6
|
+
captureGraceMs: number;
|
|
7
|
+
/** Wait before relaunching Chrome after it failed to start. */
|
|
8
|
+
relaunchBackoffMs: number;
|
|
5
9
|
maxColors: number;
|
|
6
10
|
/** Polls to keep the 'changed' sub-status active after a page change. */
|
|
7
11
|
changedPolls: number;
|
|
@@ -10,10 +14,29 @@ export declare const DEFAULTS: {
|
|
|
10
14
|
scaleDivisor: number;
|
|
11
15
|
waitUntil: string;
|
|
12
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Size limits. The pixel payload is base64(W×H) and the API caps a screen value
|
|
19
|
+
* at 220 KB, so W×H must stay under ~165k; 320×240 leaves headroom. The
|
|
20
|
+
* viewport bound keeps Chrome's raster (and the PNG decode) to sane memory.
|
|
21
|
+
*/
|
|
22
|
+
export declare const LIMITS: {
|
|
23
|
+
screenMin: number;
|
|
24
|
+
screenMaxSide: number;
|
|
25
|
+
screenMaxPixels: number;
|
|
26
|
+
viewportMin: number;
|
|
27
|
+
viewportMax: number;
|
|
28
|
+
};
|
|
13
29
|
export type WatchMode = 'auto' | 'screenshot' | 'basic';
|
|
30
|
+
export declare const WATCH_MODES: readonly WatchMode[];
|
|
31
|
+
/** puppeteer `waitUntil` values. */
|
|
32
|
+
export declare const WAIT_UNTIL: readonly ["load", "domcontentloaded", "networkidle0", "networkidle2"];
|
|
14
33
|
/** Parse a "WxH" string into a {width, height} pair, or null if malformed. */
|
|
15
34
|
export declare function parseDimensions(s: string): {
|
|
16
35
|
width: number;
|
|
17
36
|
height: number;
|
|
18
37
|
} | null;
|
|
38
|
+
/** Why a TV resolution is unusable, or null when it is fine. */
|
|
39
|
+
export declare function screenSizeProblem(w: number, h: number): string | null;
|
|
40
|
+
/** Why a browser viewport is unusable, or null when it is fine. */
|
|
41
|
+
export declare function viewportProblem(w: number, h: number): string | null;
|
|
19
42
|
//# sourceMappingURL=defaults.d.ts.map
|