@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
|
@@ -4,6 +4,7 @@ exports.SystemMonitorNPC = void 0;
|
|
|
4
4
|
const polling_npc_js_1 = require("../../core/polling-npc.js");
|
|
5
5
|
const thresholds_js_1 = require("../../core/thresholds.js");
|
|
6
6
|
const screen_js_1 = require("../../core/screen.js");
|
|
7
|
+
const errors_js_1 = require("../../core/errors.js");
|
|
7
8
|
const collectors_js_1 = require("./collectors.js");
|
|
8
9
|
const defaults_js_1 = require("./defaults.js");
|
|
9
10
|
class SystemMonitorNPC extends polling_npc_js_1.PollingNPC {
|
|
@@ -14,15 +15,21 @@ class SystemMonitorNPC extends polling_npc_js_1.PollingNPC {
|
|
|
14
15
|
cpuSampler = (0, collectors_js_1.createCpuSampler)();
|
|
15
16
|
cpuHistory = [];
|
|
16
17
|
memHistory = [];
|
|
18
|
+
diskErrorLogged = false;
|
|
17
19
|
constructor(config) {
|
|
18
20
|
super({
|
|
19
21
|
...config,
|
|
20
22
|
pollInterval: config.pollInterval ?? defaults_js_1.DEFAULTS.pollInterval,
|
|
21
23
|
});
|
|
22
24
|
this.diskPath = config.diskPath ?? defaults_js_1.DEFAULTS.diskPath;
|
|
23
|
-
this.cpuThreshold = config.cpuThreshold ?? defaults_js_1.DEFAULT_THRESHOLDS
|
|
24
|
-
this.memThreshold = config.memThreshold ?? defaults_js_1.DEFAULT_THRESHOLDS
|
|
25
|
-
this.diskThreshold = config.diskThreshold ?? defaults_js_1.DEFAULT_THRESHOLDS
|
|
25
|
+
this.cpuThreshold = config.cpuThreshold ?? defaults_js_1.DEFAULT_THRESHOLDS.cpu;
|
|
26
|
+
this.memThreshold = config.memThreshold ?? defaults_js_1.DEFAULT_THRESHOLDS.mem;
|
|
27
|
+
this.diskThreshold = config.diskThreshold ?? defaults_js_1.DEFAULT_THRESHOLDS.disk;
|
|
28
|
+
const problem = (0, thresholds_js_1.thresholdProblem)('CPU', this.cpuThreshold) ??
|
|
29
|
+
(0, thresholds_js_1.thresholdProblem)('Memory', this.memThreshold) ??
|
|
30
|
+
(0, thresholds_js_1.thresholdProblem)('Disk', this.diskThreshold);
|
|
31
|
+
if (problem)
|
|
32
|
+
throw new errors_js_1.NpcConfigError(problem);
|
|
26
33
|
}
|
|
27
34
|
get name() {
|
|
28
35
|
return 'system-monitor';
|
|
@@ -33,9 +40,6 @@ class SystemMonitorNPC extends polling_npc_js_1.PollingNPC {
|
|
|
33
40
|
get archetype() {
|
|
34
41
|
return 'service';
|
|
35
42
|
}
|
|
36
|
-
get heartbeatSeconds() {
|
|
37
|
-
return 30;
|
|
38
|
-
}
|
|
39
43
|
get behavior() {
|
|
40
44
|
return { idle: 'wander' };
|
|
41
45
|
}
|
|
@@ -49,19 +53,20 @@ class SystemMonitorNPC extends polling_npc_js_1.PollingNPC {
|
|
|
49
53
|
}
|
|
50
54
|
async collect() {
|
|
51
55
|
const cpu = this.cpuSampler.sample();
|
|
52
|
-
const mem = (0, collectors_js_1.collectMemory)();
|
|
53
|
-
const disk = await
|
|
56
|
+
const mem = (0, collectors_js_1.collectMemory)(await (0, collectors_js_1.readAvailableMemory)());
|
|
57
|
+
const disk = await this.readDisk();
|
|
54
58
|
const cpuStatus = (0, thresholds_js_1.evaluateThreshold)(cpu.percent, this.cpuThreshold);
|
|
55
59
|
const memStatus = (0, thresholds_js_1.evaluateThreshold)(mem.percent, this.memThreshold);
|
|
56
|
-
|
|
60
|
+
// An unreadable disk (unmounted volume, bad --disk-path) is a warning, not a crash.
|
|
61
|
+
const diskStatus = disk
|
|
62
|
+
? (0, thresholds_js_1.evaluateThreshold)(disk.percent, this.diskThreshold)
|
|
63
|
+
: 'warning';
|
|
57
64
|
const status = (0, thresholds_js_1.worstStatus)([cpuStatus, memStatus, diskStatus]);
|
|
58
|
-
const mood = status === 'critical'
|
|
59
|
-
? 'stressed'
|
|
60
|
-
: status === 'warning'
|
|
61
|
-
? 'concerned'
|
|
62
|
-
: 'happy';
|
|
63
65
|
(0, screen_js_1.pushHistory)(this.cpuHistory, cpu.percent);
|
|
64
66
|
(0, screen_js_1.pushHistory)(this.memHistory, mem.percent);
|
|
67
|
+
const diskText = disk ? `${disk.percent}%` : 'n/a';
|
|
68
|
+
// Windows has no load average (os.loadavg() is always zeros there).
|
|
69
|
+
const loadText = process.platform === 'win32' ? 'n/a' : cpu.loadAvg1m.toFixed(2);
|
|
65
70
|
const screenData = {
|
|
66
71
|
layout: 'multi-metric',
|
|
67
72
|
values: [
|
|
@@ -77,29 +82,48 @@ class SystemMonitorNPC extends polling_npc_js_1.PollingNPC {
|
|
|
77
82
|
color: (0, screen_js_1.statusColor)(memStatus),
|
|
78
83
|
sparkline: [...this.memHistory],
|
|
79
84
|
},
|
|
80
|
-
{ label: 'DISK', value:
|
|
81
|
-
{ label: 'LOAD', value:
|
|
85
|
+
{ label: 'DISK', value: diskText, color: (0, screen_js_1.statusColor)(diskStatus) },
|
|
86
|
+
{ label: 'LOAD', value: loadText, color: 'gray' },
|
|
82
87
|
],
|
|
83
88
|
};
|
|
89
|
+
const metrics = {
|
|
90
|
+
cpu_percent: cpu.percent,
|
|
91
|
+
mem_percent: mem.percent,
|
|
92
|
+
mem_used_gb: mem.usedGb,
|
|
93
|
+
mem_total_gb: mem.totalGb,
|
|
94
|
+
load_avg_1m: cpu.loadAvg1m,
|
|
95
|
+
};
|
|
96
|
+
if (disk) {
|
|
97
|
+
metrics['disk_percent'] = disk.percent;
|
|
98
|
+
metrics['disk_used_gb'] = disk.usedGb;
|
|
99
|
+
metrics['disk_total_gb'] = disk.totalGb;
|
|
100
|
+
}
|
|
84
101
|
return {
|
|
85
102
|
status,
|
|
86
|
-
mood,
|
|
87
|
-
message: `CPU: ${cpu.percent}% | Mem: ${mem.percent}% | Disk: ${disk
|
|
103
|
+
mood: (0, thresholds_js_1.moodForStatus)(status),
|
|
104
|
+
message: `CPU: ${cpu.percent}% | Mem: ${mem.percent}% | Disk: ${disk ? diskText : 'unavailable'}`,
|
|
88
105
|
screenData,
|
|
89
106
|
// When strained, go find a desk and get to work; otherwise wander.
|
|
90
107
|
behavior: status !== 'healthy' ? { busy: 'seek-desk' } : undefined,
|
|
91
|
-
metrics
|
|
92
|
-
cpu_percent: cpu.percent,
|
|
93
|
-
mem_percent: mem.percent,
|
|
94
|
-
mem_used_gb: mem.usedGb,
|
|
95
|
-
mem_total_gb: mem.totalGb,
|
|
96
|
-
disk_percent: disk.percent,
|
|
97
|
-
disk_used_gb: disk.usedGb,
|
|
98
|
-
disk_total_gb: disk.totalGb,
|
|
99
|
-
load_avg_1m: cpu.loadAvg1m,
|
|
100
|
-
},
|
|
108
|
+
metrics,
|
|
101
109
|
};
|
|
102
110
|
}
|
|
111
|
+
async readDisk() {
|
|
112
|
+
try {
|
|
113
|
+
const disk = await (0, collectors_js_1.collectDisk)(this.diskPath);
|
|
114
|
+
this.diskErrorLogged = false;
|
|
115
|
+
return disk;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (!this.diskErrorLogged) {
|
|
119
|
+
this.diskErrorLogged = true;
|
|
120
|
+
this.logger.warn(`Cannot read disk usage for ${this.diskPath}`, {
|
|
121
|
+
error: err instanceof Error ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
103
127
|
}
|
|
104
128
|
exports.SystemMonitorNPC = SystemMonitorNPC;
|
|
105
129
|
//# sourceMappingURL=index.js.map
|
|
@@ -4,10 +4,31 @@ exports.extractTitle = extractTitle;
|
|
|
4
4
|
exports.statusFromHttp = statusFromHttp;
|
|
5
5
|
exports.basicCheck = basicCheck;
|
|
6
6
|
exports.hostnameOf = hostnameOf;
|
|
7
|
-
|
|
7
|
+
const http_js_1 = require("../../core/http.js");
|
|
8
|
+
const text_js_1 = require("../../core/text.js");
|
|
9
|
+
/** Bytes of HTML read to find the <title> (it lives in <head>). */
|
|
10
|
+
const MAX_HTML_BYTES = 512 * 1024;
|
|
11
|
+
const ENTITIES = {
|
|
12
|
+
amp: '&',
|
|
13
|
+
lt: '<',
|
|
14
|
+
gt: '>',
|
|
15
|
+
quot: '"',
|
|
16
|
+
apos: "'",
|
|
17
|
+
nbsp: ' ',
|
|
18
|
+
};
|
|
19
|
+
function decodeEntities(s) {
|
|
20
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
21
|
+
if (e[0] === '#') {
|
|
22
|
+
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
23
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
|
|
24
|
+
}
|
|
25
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Extract the <title> text from (untrusted) HTML: entities decoded, sanitized, ≤80 chars. */
|
|
8
29
|
function extractTitle(html) {
|
|
9
30
|
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
10
|
-
return m ?
|
|
31
|
+
return m ? (0, text_js_1.sanitizeText)(decodeEntities(m[1]), 80) : '';
|
|
11
32
|
}
|
|
12
33
|
/** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
|
|
13
34
|
function statusFromHttp(code, timedOut = false) {
|
|
@@ -21,17 +42,15 @@ function statusFromHttp(code, timedOut = false) {
|
|
|
21
42
|
}
|
|
22
43
|
/** Fetch a page and extract HTTP status + <title> (basic, no browser). */
|
|
23
44
|
async function basicCheck(url, timeoutMs = 8000) {
|
|
24
|
-
const controller = new AbortController();
|
|
25
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
26
45
|
try {
|
|
27
46
|
const res = await fetch(url, {
|
|
28
|
-
signal:
|
|
29
|
-
headers: { 'User-Agent':
|
|
47
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
48
|
+
headers: { 'User-Agent': http_js_1.NPC_USER_AGENT },
|
|
30
49
|
redirect: 'follow',
|
|
31
50
|
});
|
|
32
51
|
let title = '';
|
|
33
52
|
try {
|
|
34
|
-
title = extractTitle(await res.text
|
|
53
|
+
title = extractTitle((await (0, http_js_1.readTextCapped)(res, MAX_HTML_BYTES)).text);
|
|
35
54
|
}
|
|
36
55
|
catch {
|
|
37
56
|
// body unavailable; leave title blank
|
|
@@ -39,20 +58,17 @@ async function basicCheck(url, timeoutMs = 8000) {
|
|
|
39
58
|
return { status: res.status, title, ok: res.ok, timedOut: false };
|
|
40
59
|
}
|
|
41
60
|
catch (err) {
|
|
42
|
-
const timedOut = err instanceof Error && err.name === 'AbortError';
|
|
61
|
+
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
43
62
|
return { status: 0, title: '', ok: false, timedOut };
|
|
44
63
|
}
|
|
45
|
-
finally {
|
|
46
|
-
clearTimeout(timer);
|
|
47
|
-
}
|
|
48
64
|
}
|
|
49
|
-
/**
|
|
65
|
+
/** Hostname of a URL for labels and service ids (never credentials or path). */
|
|
50
66
|
function hostnameOf(url) {
|
|
51
67
|
try {
|
|
52
|
-
return new URL(url).hostname;
|
|
68
|
+
return new URL(url).hostname || 'page';
|
|
53
69
|
}
|
|
54
70
|
catch {
|
|
55
|
-
return url
|
|
71
|
+
return 'invalid-url';
|
|
56
72
|
}
|
|
57
73
|
}
|
|
58
74
|
//# sourceMappingURL=basic-mode.js.map
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.findChrome = findChrome;
|
|
4
|
+
exports.chromeArgs = chromeArgs;
|
|
5
|
+
exports.runningAsRoot = runningAsRoot;
|
|
4
6
|
exports.loadPuppeteer = loadPuppeteer;
|
|
5
7
|
exports.browserConnected = browserConnected;
|
|
6
8
|
const node_fs_1 = require("node:fs");
|
|
@@ -21,7 +23,11 @@ const LINUX_BINARIES = [
|
|
|
21
23
|
];
|
|
22
24
|
function which(bin) {
|
|
23
25
|
try {
|
|
24
|
-
const out = (0, node_child_process_1.execFileSync)('which', [bin], {
|
|
26
|
+
const out = (0, node_child_process_1.execFileSync)('which', [bin], {
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
timeout: 2000,
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
}).trim();
|
|
25
31
|
return out || null;
|
|
26
32
|
}
|
|
27
33
|
catch {
|
|
@@ -54,6 +60,22 @@ function findChrome(explicit) {
|
|
|
54
60
|
}
|
|
55
61
|
return null;
|
|
56
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Chrome launch flags. The watched page is untrusted content, so Chrome's
|
|
65
|
+
* sandbox stays on unless it can't work: as root on Linux (Chrome refuses to
|
|
66
|
+
* start sandboxed, typical in containers) or when explicitly disabled.
|
|
67
|
+
*/
|
|
68
|
+
function chromeArgs(opts) {
|
|
69
|
+
const args = ['--disable-gpu', '--hide-scrollbars', '--mute-audio', '--no-first-run'];
|
|
70
|
+
const sandboxed = opts.sandbox && !opts.isRoot;
|
|
71
|
+
if (!sandboxed)
|
|
72
|
+
args.push('--no-sandbox', '--disable-setuid-sandbox');
|
|
73
|
+
return { args, sandboxed };
|
|
74
|
+
}
|
|
75
|
+
/** True when this process runs as root (POSIX only). */
|
|
76
|
+
function runningAsRoot() {
|
|
77
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
78
|
+
}
|
|
57
79
|
/**
|
|
58
80
|
* Load the optional `puppeteer-core` peer dependency without a static import,
|
|
59
81
|
* so the package builds and runs when it isn't installed. Returns null if the
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULTS = void 0;
|
|
3
|
+
exports.WAIT_UNTIL = exports.WATCH_MODES = exports.LIMITS = exports.DEFAULTS = void 0;
|
|
4
4
|
exports.parseDimensions = parseDimensions;
|
|
5
|
+
exports.screenSizeProblem = screenSizeProblem;
|
|
6
|
+
exports.viewportProblem = viewportProblem;
|
|
5
7
|
exports.DEFAULTS = {
|
|
6
8
|
pollInterval: 60000, // 60s
|
|
7
9
|
minPollInterval: 15000, // floor
|
|
8
10
|
navTimeoutMs: 15000,
|
|
11
|
+
/** Extra time for screenshot + encode before a capture counts as hung. */
|
|
12
|
+
captureGraceMs: 15000,
|
|
13
|
+
/** Wait before relaunching Chrome after it failed to start. */
|
|
14
|
+
relaunchBackoffMs: 10 * 60 * 1000,
|
|
9
15
|
maxColors: 32,
|
|
10
16
|
/** Polls to keep the 'changed' sub-status active after a page change. */
|
|
11
17
|
changedPolls: 2,
|
|
@@ -18,6 +24,21 @@ exports.DEFAULTS = {
|
|
|
18
24
|
scaleDivisor: 8,
|
|
19
25
|
waitUntil: 'networkidle2',
|
|
20
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Size limits. The pixel payload is base64(W×H) and the API caps a screen value
|
|
29
|
+
* at 220 KB, so W×H must stay under ~165k; 320×240 leaves headroom. The
|
|
30
|
+
* viewport bound keeps Chrome's raster (and the PNG decode) to sane memory.
|
|
31
|
+
*/
|
|
32
|
+
exports.LIMITS = {
|
|
33
|
+
screenMin: 16,
|
|
34
|
+
screenMaxSide: 320,
|
|
35
|
+
screenMaxPixels: 320 * 240,
|
|
36
|
+
viewportMin: 64,
|
|
37
|
+
viewportMax: 4096,
|
|
38
|
+
};
|
|
39
|
+
exports.WATCH_MODES = ['auto', 'screenshot', 'basic'];
|
|
40
|
+
/** puppeteer `waitUntil` values. */
|
|
41
|
+
exports.WAIT_UNTIL = ['load', 'domcontentloaded', 'networkidle0', 'networkidle2'];
|
|
21
42
|
/** Parse a "WxH" string into a {width, height} pair, or null if malformed. */
|
|
22
43
|
function parseDimensions(s) {
|
|
23
44
|
const m = s
|
|
@@ -26,6 +47,27 @@ function parseDimensions(s) {
|
|
|
26
47
|
.match(/^(\d+)x(\d+)$/);
|
|
27
48
|
if (!m)
|
|
28
49
|
return null;
|
|
29
|
-
|
|
50
|
+
const width = parseInt(m[1], 10);
|
|
51
|
+
const height = parseInt(m[2], 10);
|
|
52
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
53
|
+
}
|
|
54
|
+
/** Why a TV resolution is unusable, or null when it is fine. */
|
|
55
|
+
function screenSizeProblem(w, h) {
|
|
56
|
+
const { screenMin, screenMaxSide, screenMaxPixels } = exports.LIMITS;
|
|
57
|
+
if (!Number.isInteger(w) || !Number.isInteger(h) || w < screenMin || h < screenMin) {
|
|
58
|
+
return `--screen must be at least ${screenMin}x${screenMin}`;
|
|
59
|
+
}
|
|
60
|
+
if (w > screenMaxSide || h > screenMaxSide || w * h > screenMaxPixels) {
|
|
61
|
+
return `--screen must fit within ${screenMaxSide}x${screenMaxSide} and ${screenMaxPixels} pixels (e.g. 320x240)`;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/** Why a browser viewport is unusable, or null when it is fine. */
|
|
66
|
+
function viewportProblem(w, h) {
|
|
67
|
+
const { viewportMin, viewportMax } = exports.LIMITS;
|
|
68
|
+
const ok = (n) => Number.isInteger(n) && n >= viewportMin && n <= viewportMax;
|
|
69
|
+
return ok(w) && ok(h)
|
|
70
|
+
? null
|
|
71
|
+
: `--viewport sides must be between ${viewportMin} and ${viewportMax} pixels`;
|
|
30
72
|
}
|
|
31
73
|
//# sourceMappingURL=defaults.js.map
|
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WebPageWatcherNPC = void 0;
|
|
4
4
|
const polling_npc_js_1 = require("../../core/polling-npc.js");
|
|
5
|
+
const thresholds_js_1 = require("../../core/thresholds.js");
|
|
6
|
+
const screen_js_1 = require("../../core/screen.js");
|
|
7
|
+
const errors_js_1 = require("../../core/errors.js");
|
|
8
|
+
const http_js_1 = require("../../core/http.js");
|
|
5
9
|
const defaults_js_1 = require("./defaults.js");
|
|
6
10
|
const pixelate_js_1 = require("./pixelate.js");
|
|
7
11
|
const capture_js_1 = require("./capture.js");
|
|
8
12
|
const basic_mode_js_1 = require("./basic-mode.js");
|
|
13
|
+
/** Thrown when a capture exceeds its deadline (the browser is then recycled). */
|
|
14
|
+
class CaptureTimeout extends Error {
|
|
15
|
+
}
|
|
9
16
|
class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
10
17
|
url;
|
|
18
|
+
host;
|
|
11
19
|
mode;
|
|
12
20
|
chromePath;
|
|
21
|
+
chromeSandbox;
|
|
13
22
|
viewportW;
|
|
14
23
|
viewportH;
|
|
15
24
|
screenW;
|
|
@@ -17,7 +26,10 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
17
26
|
waitUntil;
|
|
18
27
|
puppeteer = null;
|
|
19
28
|
browser = null;
|
|
20
|
-
|
|
29
|
+
/** puppeteer-core or Chrome is missing: never retried. */
|
|
30
|
+
browserMissing = false;
|
|
31
|
+
/** Chrome failed to launch: retry after this time (epoch ms). */
|
|
32
|
+
nextLaunchAttempt = 0;
|
|
21
33
|
chromeHintShown = false;
|
|
22
34
|
lastHash = 0;
|
|
23
35
|
changedPollsLeft = 0;
|
|
@@ -27,22 +39,41 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
27
39
|
// Floor the poll interval — screenshots are expensive.
|
|
28
40
|
pollInterval: Math.max(config.pollInterval ?? defaults_js_1.DEFAULTS.pollInterval, defaults_js_1.DEFAULTS.minPollInterval),
|
|
29
41
|
});
|
|
42
|
+
// The page is rendered and shown to the whole office: http(s) only, never
|
|
43
|
+
// file:, data:, chrome: or javascript: URLs.
|
|
44
|
+
if (!(0, http_js_1.isHttpUrl)(config.url)) {
|
|
45
|
+
throw new errors_js_1.NpcConfigError('--url must be an http(s) URL');
|
|
46
|
+
}
|
|
30
47
|
this.url = config.url;
|
|
31
|
-
this.
|
|
48
|
+
this.host = (0, basic_mode_js_1.hostnameOf)(config.url);
|
|
49
|
+
const mode = config.mode ?? 'auto';
|
|
50
|
+
if (!defaults_js_1.WATCH_MODES.includes(mode)) {
|
|
51
|
+
throw new errors_js_1.NpcConfigError(`--mode must be one of ${defaults_js_1.WATCH_MODES.join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
this.mode = mode;
|
|
54
|
+
const waitUntil = config.waitUntil ?? defaults_js_1.DEFAULTS.waitUntil;
|
|
55
|
+
if (!defaults_js_1.WAIT_UNTIL.includes(waitUntil)) {
|
|
56
|
+
throw new errors_js_1.NpcConfigError(`--wait-until must be one of ${defaults_js_1.WAIT_UNTIL.join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
this.waitUntil = waitUntil;
|
|
32
59
|
this.chromePath = config.chromePath;
|
|
60
|
+
this.chromeSandbox = config.chromeSandbox ?? true;
|
|
33
61
|
this.screenW = config.screenWidth ?? defaults_js_1.DEFAULTS.screenW;
|
|
34
62
|
this.screenH = config.screenHeight ?? defaults_js_1.DEFAULTS.screenH;
|
|
35
63
|
// Derive the viewport from the screen size for native downscaling unless the
|
|
36
64
|
// user overrides it (in which case pngToPixelImage box-filters to screen).
|
|
37
65
|
this.viewportW = config.viewportWidth ?? this.screenW * defaults_js_1.DEFAULTS.scaleDivisor;
|
|
38
66
|
this.viewportH = config.viewportHeight ?? this.screenH * defaults_js_1.DEFAULTS.scaleDivisor;
|
|
39
|
-
|
|
67
|
+
const sizeProblem = (0, defaults_js_1.screenSizeProblem)(this.screenW, this.screenH) ??
|
|
68
|
+
(0, defaults_js_1.viewportProblem)(this.viewportW, this.viewportH);
|
|
69
|
+
if (sizeProblem)
|
|
70
|
+
throw new errors_js_1.NpcConfigError(sizeProblem);
|
|
40
71
|
}
|
|
41
72
|
get name() {
|
|
42
73
|
return 'web-page-watcher';
|
|
43
74
|
}
|
|
44
75
|
get defaultDisplayName() {
|
|
45
|
-
return
|
|
76
|
+
return this.host;
|
|
46
77
|
}
|
|
47
78
|
get archetype() {
|
|
48
79
|
return 'custom';
|
|
@@ -52,43 +83,58 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
52
83
|
screenType: 'web-view',
|
|
53
84
|
size: { w: 4, h: 3 },
|
|
54
85
|
placement: 'adjacent-wall',
|
|
55
|
-
label:
|
|
86
|
+
label: this.host,
|
|
56
87
|
};
|
|
57
88
|
}
|
|
58
89
|
async collect() {
|
|
59
90
|
if (this.mode === 'basic')
|
|
60
|
-
return this.collectBasic();
|
|
91
|
+
return this.collectBasic(false);
|
|
61
92
|
const browser = await this.ensureBrowser();
|
|
62
93
|
if (!browser) {
|
|
63
|
-
// 'auto' silently degrades; explicit 'screenshot' warns once.
|
|
64
94
|
this.maybeShowChromeHint();
|
|
65
|
-
return this.collectBasic();
|
|
95
|
+
return this.collectBasic(true);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await this.withDeadline(this.collectScreenshot(browser));
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (this.state !== 'running')
|
|
102
|
+
throw err; // shutting down: nothing to report
|
|
103
|
+
// A crashed or wedged browser: recycle it and report this cycle from a plain fetch.
|
|
104
|
+
this.logger.warn('Screenshot failed; using a basic check this cycle', {
|
|
105
|
+
error: err instanceof Error ? err.message : String(err),
|
|
106
|
+
});
|
|
107
|
+
await this.closeBrowser();
|
|
108
|
+
return this.collectBasic(true);
|
|
66
109
|
}
|
|
67
|
-
return this.collectScreenshot(browser);
|
|
68
110
|
}
|
|
69
111
|
async onStop() {
|
|
112
|
+
// Close Chrome first: it aborts an in-flight capture, so the base class
|
|
113
|
+
// doesn't wait out a slow page load before pushing offline.
|
|
114
|
+
await this.closeBrowser();
|
|
70
115
|
await super.onStop();
|
|
71
|
-
if (this.browser) {
|
|
72
|
-
try {
|
|
73
|
-
await this.browser.close();
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
// best effort
|
|
77
|
-
}
|
|
78
|
-
this.browser = null;
|
|
79
|
-
}
|
|
80
116
|
}
|
|
81
117
|
// --- Screenshot mode ---
|
|
118
|
+
withDeadline(work) {
|
|
119
|
+
const ms = defaults_js_1.DEFAULTS.navTimeoutMs + defaults_js_1.DEFAULTS.captureGraceMs;
|
|
120
|
+
let timer;
|
|
121
|
+
const deadline = new Promise((_, reject) => {
|
|
122
|
+
timer = setTimeout(() => reject(new CaptureTimeout(`capture exceeded ${ms}ms`)), ms);
|
|
123
|
+
});
|
|
124
|
+
return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
|
|
125
|
+
}
|
|
82
126
|
async collectScreenshot(browser) {
|
|
83
127
|
const page = await browser.newPage();
|
|
84
128
|
try {
|
|
85
|
-
//
|
|
86
|
-
|
|
129
|
+
// A page that alert()s would block navigation and the screenshot.
|
|
130
|
+
page.on?.('dialog', (dialog) => void dialog.dismiss().catch(() => undefined));
|
|
131
|
+
// deviceScaleFactor 1/8 makes Chrome rasterize the viewport down to the
|
|
132
|
+
// screen size natively (e.g. 1280×960 → 160×120); pngToPixelImage
|
|
87
133
|
// box-filters as a fallback if it doesn't.
|
|
88
134
|
await page.setViewport({
|
|
89
135
|
width: this.viewportW,
|
|
90
136
|
height: this.viewportH,
|
|
91
|
-
deviceScaleFactor:
|
|
137
|
+
deviceScaleFactor: 1 / defaults_js_1.DEFAULTS.scaleDivisor,
|
|
92
138
|
});
|
|
93
139
|
let httpStatus = 0;
|
|
94
140
|
let timedOut = false;
|
|
@@ -116,15 +162,14 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
116
162
|
else if (this.changedPollsLeft > 0)
|
|
117
163
|
this.changedPollsLeft--;
|
|
118
164
|
const status = (0, basic_mode_js_1.statusFromHttp)(httpStatus, timedOut);
|
|
119
|
-
const host = (0, basic_mode_js_1.hostnameOf)(this.url);
|
|
120
165
|
const message = changed
|
|
121
166
|
? 'Page updated!'
|
|
122
167
|
: httpStatus
|
|
123
|
-
? `${host} ${httpStatus < 400 ? 'OK' : 'ERR'} (${httpStatus}, ${loadMs}ms)`
|
|
124
|
-
: `${host} unreachable`;
|
|
168
|
+
? `${this.host} ${httpStatus < 400 ? 'OK' : 'ERR'} (${httpStatus}, ${loadMs}ms)`
|
|
169
|
+
: `${this.host} ${timedOut ? 'timed out' : 'unreachable'}`;
|
|
125
170
|
return {
|
|
126
171
|
status,
|
|
127
|
-
mood:
|
|
172
|
+
mood: (0, thresholds_js_1.moodForStatus)(status),
|
|
128
173
|
message,
|
|
129
174
|
subStatus: this.changedPollsLeft > 0 ? 'changed' : undefined,
|
|
130
175
|
screenData,
|
|
@@ -141,74 +186,92 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
141
186
|
}
|
|
142
187
|
}
|
|
143
188
|
async ensureBrowser() {
|
|
144
|
-
if (this.
|
|
189
|
+
if (this.browserMissing || Date.now() < this.nextLaunchAttempt)
|
|
145
190
|
return null;
|
|
146
191
|
if (this.browser && (0, capture_js_1.browserConnected)(this.browser))
|
|
147
192
|
return this.browser;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (!this.puppeteer) {
|
|
151
|
-
this.puppeteer = await (0, capture_js_1.loadPuppeteer)();
|
|
152
|
-
}
|
|
193
|
+
this.browser = null; // absent or disconnected — (re)launch
|
|
194
|
+
this.puppeteer ??= await (0, capture_js_1.loadPuppeteer)();
|
|
153
195
|
const chromePath = (0, capture_js_1.findChrome)(this.chromePath);
|
|
154
196
|
if (!this.puppeteer || !chromePath) {
|
|
155
|
-
this.
|
|
197
|
+
this.browserMissing = true;
|
|
156
198
|
return null;
|
|
157
199
|
}
|
|
200
|
+
const { args, sandboxed } = (0, capture_js_1.chromeArgs)({
|
|
201
|
+
sandbox: this.chromeSandbox,
|
|
202
|
+
isRoot: (0, capture_js_1.runningAsRoot)(),
|
|
203
|
+
});
|
|
204
|
+
if (!sandboxed) {
|
|
205
|
+
this.logger.warn('Chrome sandbox disabled' +
|
|
206
|
+
(this.chromeSandbox ? ' (running as root)' : '') +
|
|
207
|
+
': only watch pages you trust');
|
|
208
|
+
}
|
|
158
209
|
try {
|
|
159
|
-
|
|
210
|
+
const browser = await this.puppeteer.launch({
|
|
160
211
|
executablePath: chromePath,
|
|
161
212
|
headless: true,
|
|
162
|
-
args
|
|
213
|
+
args,
|
|
163
214
|
});
|
|
164
|
-
|
|
165
|
-
this.browser
|
|
215
|
+
browser.on('disconnected', () => {
|
|
216
|
+
if (this.browser === browser)
|
|
217
|
+
this.browser = null;
|
|
166
218
|
});
|
|
167
|
-
|
|
219
|
+
// Stopped while Chrome was starting: don't leave it running.
|
|
220
|
+
if (this.state !== 'running') {
|
|
221
|
+
await browser.close().catch(() => undefined);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
this.browser = browser;
|
|
225
|
+
return browser;
|
|
168
226
|
}
|
|
169
227
|
catch (err) {
|
|
170
|
-
this.logger.warn('Failed to launch Chrome', {
|
|
228
|
+
this.logger.warn('Failed to launch Chrome; retrying in 10 minutes', {
|
|
171
229
|
error: err instanceof Error ? err.message : String(err),
|
|
172
230
|
});
|
|
173
|
-
this.
|
|
231
|
+
this.nextLaunchAttempt = Date.now() + defaults_js_1.DEFAULTS.relaunchBackoffMs;
|
|
174
232
|
return null;
|
|
175
233
|
}
|
|
176
234
|
}
|
|
235
|
+
async closeBrowser() {
|
|
236
|
+
const browser = this.browser;
|
|
237
|
+
this.browser = null;
|
|
238
|
+
if (browser)
|
|
239
|
+
await browser.close().catch(() => undefined);
|
|
240
|
+
}
|
|
177
241
|
maybeShowChromeHint() {
|
|
178
|
-
if (this.chromeHintShown)
|
|
242
|
+
if (this.chromeHintShown || !this.browserMissing)
|
|
179
243
|
return;
|
|
180
244
|
this.chromeHintShown = true;
|
|
181
245
|
this.logger.warn('Chrome/puppeteer-core unavailable — falling back to basic mode. ' +
|
|
182
246
|
'Install `puppeteer-core` and Google Chrome (or set --chrome-path/CHROME_PATH) for the pixel-art TV.');
|
|
183
247
|
}
|
|
184
248
|
// --- Basic mode ---
|
|
185
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Plain fetch: HTTP status + page title on a ticker. When this is a fallback
|
|
251
|
+
* from screenshot mode (no Chrome), a healthy page is reported as a warning
|
|
252
|
+
* to nudge the Chrome setup; an explicit `--mode=basic` reports it healthy.
|
|
253
|
+
*/
|
|
254
|
+
async collectBasic(fallback) {
|
|
186
255
|
const loadStart = Date.now();
|
|
187
|
-
const check = await (0, basic_mode_js_1.basicCheck)(this.url);
|
|
256
|
+
const check = await (0, basic_mode_js_1.basicCheck)(this.url, defaults_js_1.DEFAULTS.navTimeoutMs);
|
|
188
257
|
const loadMs = Date.now() - loadStart;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
status
|
|
194
|
-
const host = (0, basic_mode_js_1.hostnameOf)(this.url);
|
|
195
|
-
const message = check.title ? `${host}: ${check.title}` : `${host} (${check.status || 'ERR'})`;
|
|
258
|
+
const httpStatus = (0, basic_mode_js_1.statusFromHttp)(check.status, check.timedOut);
|
|
259
|
+
const status = fallback && httpStatus === 'healthy' ? 'warning' : httpStatus;
|
|
260
|
+
const message = check.title
|
|
261
|
+
? `${this.host}: ${check.title}`
|
|
262
|
+
: `${this.host} (${check.status || (check.timedOut ? 'timed out' : 'ERR')})`;
|
|
196
263
|
return {
|
|
197
264
|
status,
|
|
198
|
-
mood:
|
|
265
|
+
mood: (0, thresholds_js_1.moodForStatus)(status),
|
|
199
266
|
message,
|
|
200
267
|
screenData: {
|
|
201
268
|
layout: 'ticker',
|
|
202
269
|
values: [
|
|
203
|
-
{ label: 'URL', value: host },
|
|
270
|
+
{ label: 'URL', value: this.host },
|
|
204
271
|
{
|
|
205
272
|
label: 'HTTP',
|
|
206
273
|
value: check.status ? String(check.status) : 'ERR',
|
|
207
|
-
color:
|
|
208
|
-
? 'green'
|
|
209
|
-
: check.status >= 500 || check.status === 0
|
|
210
|
-
? 'red'
|
|
211
|
-
: 'amber',
|
|
274
|
+
color: (0, screen_js_1.statusColor)(httpStatus),
|
|
212
275
|
},
|
|
213
276
|
{ label: 'Title', value: check.title || '(no title)' },
|
|
214
277
|
{ label: 'Mode', value: 'basic' },
|
|
@@ -219,16 +282,4 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
219
282
|
}
|
|
220
283
|
}
|
|
221
284
|
exports.WebPageWatcherNPC = WebPageWatcherNPC;
|
|
222
|
-
function moodFor(status) {
|
|
223
|
-
switch (status) {
|
|
224
|
-
case 'healthy':
|
|
225
|
-
return 'happy';
|
|
226
|
-
case 'warning':
|
|
227
|
-
return 'concerned';
|
|
228
|
-
case 'critical':
|
|
229
|
-
return 'stressed';
|
|
230
|
-
default:
|
|
231
|
-
return 'idle';
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
285
|
//# sourceMappingURL=index.js.map
|
|
@@ -176,6 +176,7 @@ function pngToPixelImage(pngBuffer, maxColors = 32, targetW = exports.TARGET_W,
|
|
|
176
176
|
const { palette, indices } = quantize(rgba, w * h, maxColors);
|
|
177
177
|
const screenData = buildPixelImageScreen(w, h, palette, indices);
|
|
178
178
|
const byteSize = screenData.values.reduce((sum, v) => sum + v.value.length, 0);
|
|
179
|
-
|
|
179
|
+
// Hash the palette too: a recolour with the same layout is still a change.
|
|
180
|
+
return { screenData, byteSize, hash: fnv1a(Buffer.concat([palette, indices])) };
|
|
180
181
|
}
|
|
181
182
|
//# sourceMappingURL=pixelate.js.map
|