@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,49 +1,87 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.LogWatcherNPC = void 0;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
4
5
|
const node_path_1 = require("node:path");
|
|
5
|
-
const
|
|
6
|
+
const polling_npc_js_1 = require("../../core/polling-npc.js");
|
|
6
7
|
const thresholds_js_1 = require("../../core/thresholds.js");
|
|
7
|
-
const
|
|
8
|
+
const errors_js_1 = require("../../core/errors.js");
|
|
8
9
|
const tail_js_1 = require("./tail.js");
|
|
9
10
|
const buffer_js_1 = require("./buffer.js");
|
|
10
11
|
const defaults_js_1 = require("./defaults.js");
|
|
11
|
-
|
|
12
|
+
/** Lines shown on the log terminal, and their width. */
|
|
13
|
+
const SCREEN_LINES = 12;
|
|
14
|
+
const SCREEN_LINE_CHARS = 80;
|
|
15
|
+
function compilePattern(flag, source) {
|
|
16
|
+
try {
|
|
17
|
+
return new RegExp(source);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
throw new errors_js_1.NpcConfigError(`${flag}: invalid regular expression (${err instanceof Error ? err.message : String(err)})`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** "45s", "5min", "1.5h" */
|
|
24
|
+
function formatWindow(seconds) {
|
|
25
|
+
if (seconds < 60)
|
|
26
|
+
return `${seconds}s`;
|
|
27
|
+
if (seconds < 3600)
|
|
28
|
+
return `${Math.round((seconds / 60) * 10) / 10}min`;
|
|
29
|
+
return `${Math.round((seconds / 3600) * 10) / 10}h`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Tails log files and reports error bursts. A periodic report (every
|
|
33
|
+
* `reportInterval`) carries the error count over the sliding window and the
|
|
34
|
+
* last lines for the log terminal; a fresh error line also triggers an
|
|
35
|
+
* immediate report, debounced to one per 5s, so a burst shows up at once.
|
|
36
|
+
* Every report is one composed push, never a push per line.
|
|
37
|
+
*/
|
|
38
|
+
class LogWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
12
39
|
tailers = [];
|
|
13
|
-
|
|
14
|
-
entries = [];
|
|
40
|
+
counter;
|
|
15
41
|
recentLines = [];
|
|
16
|
-
|
|
42
|
+
lastError = null;
|
|
43
|
+
/** The error line that triggered an immediate report, consumed by the next collect(). */
|
|
44
|
+
burstLine = null;
|
|
45
|
+
lastBurstAt = 0;
|
|
17
46
|
errorRegex;
|
|
18
47
|
warningRegex;
|
|
19
48
|
windowMs;
|
|
20
|
-
|
|
21
|
-
errorCrit;
|
|
22
|
-
reportInterval;
|
|
49
|
+
errorThreshold;
|
|
23
50
|
files;
|
|
24
51
|
constructor(config) {
|
|
25
|
-
super(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
this.
|
|
30
|
-
this.
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
super({
|
|
53
|
+
...config,
|
|
54
|
+
pollInterval: config.reportInterval ?? config.pollInterval ?? defaults_js_1.DEFAULTS.reportInterval,
|
|
55
|
+
});
|
|
56
|
+
this.files = [...new Set(config.files.map((f) => f.trim()).filter(Boolean))].map((f) => (0, node_path_1.resolve)(f));
|
|
57
|
+
if (this.files.length === 0) {
|
|
58
|
+
throw new errors_js_1.NpcConfigError('log-watcher needs at least one file (--files)');
|
|
59
|
+
}
|
|
60
|
+
this.errorRegex = compilePattern('--error-pattern', config.errorPattern ?? defaults_js_1.DEFAULTS.errorPattern);
|
|
61
|
+
this.warningRegex = compilePattern('--warning-pattern', config.warningPattern ?? defaults_js_1.DEFAULTS.warningPattern);
|
|
62
|
+
const windowSeconds = config.windowSeconds ?? defaults_js_1.DEFAULTS.windowSeconds;
|
|
63
|
+
if (!(windowSeconds > 0))
|
|
64
|
+
throw new errors_js_1.NpcConfigError('--window must be a positive number of seconds');
|
|
65
|
+
this.windowMs = windowSeconds * 1000;
|
|
66
|
+
this.counter = new buffer_js_1.WindowCounter(this.windowMs);
|
|
67
|
+
this.errorThreshold = {
|
|
68
|
+
warning: config.errorWarn ?? defaults_js_1.DEFAULTS.errorWarn,
|
|
69
|
+
critical: config.errorCrit ?? defaults_js_1.DEFAULTS.errorCrit,
|
|
70
|
+
direction: 'above',
|
|
71
|
+
};
|
|
72
|
+
const problem = (0, thresholds_js_1.thresholdProblem)('--error-warn/--error-crit', this.errorThreshold);
|
|
73
|
+
if (problem)
|
|
74
|
+
throw new errors_js_1.NpcConfigError(problem);
|
|
33
75
|
}
|
|
34
76
|
get name() {
|
|
35
77
|
return 'log-watcher';
|
|
36
78
|
}
|
|
37
79
|
get defaultDisplayName() {
|
|
38
|
-
|
|
39
|
-
return first ? `Logs: ${(0, node_path_1.basename)(first)}` : 'Logs';
|
|
80
|
+
return `Logs: ${(0, node_path_1.basename)(this.files[0])}`;
|
|
40
81
|
}
|
|
41
82
|
get archetype() {
|
|
42
83
|
return 'service';
|
|
43
84
|
}
|
|
44
|
-
get heartbeatSeconds() {
|
|
45
|
-
return 30;
|
|
46
|
-
}
|
|
47
85
|
get behavior() {
|
|
48
86
|
return { idle: 'wander' };
|
|
49
87
|
}
|
|
@@ -52,109 +90,77 @@ class LogWatcherNPC extends base_npc_js_1.BaseNPC {
|
|
|
52
90
|
screenType: 'log-terminal',
|
|
53
91
|
size: { w: 2, h: 2 },
|
|
54
92
|
placement: 'adjacent-wall',
|
|
55
|
-
label:
|
|
93
|
+
label: (0, node_path_1.basename)(this.files[0]),
|
|
56
94
|
};
|
|
57
95
|
}
|
|
58
96
|
async onStart() {
|
|
59
97
|
for (const filePath of this.files) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
98
|
+
if ((0, node_fs_1.existsSync)(filePath)) {
|
|
99
|
+
this.logger.info(`Tailing ${filePath}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
this.logger.warn(`${filePath} does not exist yet; will tail it once it appears`);
|
|
103
|
+
}
|
|
104
|
+
this.tailers.push((0, tail_js_1.createTailer)(filePath, (line) => this.handleLine(line), {
|
|
105
|
+
onError: (err) => this.logger.warn(`Cannot read ${filePath}`, { error: err.message }),
|
|
106
|
+
}));
|
|
65
107
|
}
|
|
66
|
-
|
|
67
|
-
await this.report();
|
|
68
|
-
this.timer = setInterval(() => {
|
|
69
|
-
void this.report();
|
|
70
|
-
}, this.reportInterval);
|
|
108
|
+
await super.onStart();
|
|
71
109
|
}
|
|
72
110
|
async onStop() {
|
|
73
|
-
|
|
74
|
-
clearInterval(this.timer);
|
|
75
|
-
this.timer = null;
|
|
76
|
-
}
|
|
77
|
-
for (const tailer of this.tailers) {
|
|
111
|
+
for (const tailer of this.tailers)
|
|
78
112
|
tailer.close();
|
|
79
|
-
}
|
|
80
113
|
this.tailers = [];
|
|
114
|
+
await super.onStop();
|
|
81
115
|
}
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
/** Visible for tests: feed one raw line as if a tailer had read it. */
|
|
117
|
+
handleLine(line, now = Date.now()) {
|
|
84
118
|
const level = (0, buffer_js_1.classifyLine)(line, this.errorRegex, this.warningRegex);
|
|
85
|
-
//
|
|
86
|
-
(0, buffer_js_1.pushRecent)(this.recentLines, line, level);
|
|
87
|
-
if (level === '
|
|
88
|
-
this.entries.push({ timestamp: now, type: 'error', message: line });
|
|
89
|
-
this.onErrorBurst(line);
|
|
90
|
-
}
|
|
91
|
-
else if (level === 'warning') {
|
|
92
|
-
this.entries.push({ timestamp: now, type: 'warning', message: line });
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
/** A fresh error triggers an immediate push, debounced to at most once / 5s. */
|
|
96
|
-
onErrorBurst(line) {
|
|
97
|
-
const now = Date.now();
|
|
98
|
-
if (!(0, buffer_js_1.shouldEmitBurst)(this.lastBurstPush, now))
|
|
119
|
+
// The ring buffer holds ALL lines (matched ones flagged) for the log TV.
|
|
120
|
+
(0, buffer_js_1.pushRecent)(this.recentLines, (0, buffer_js_1.cleanLogLine)(line, SCREEN_LINE_CHARS), level, SCREEN_LINES);
|
|
121
|
+
if (level === 'info')
|
|
99
122
|
return;
|
|
100
|
-
this.
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
pruneWindow() {
|
|
104
|
-
const cutoff = Date.now() - this.windowMs;
|
|
105
|
-
this.entries = this.entries.filter((e) => e.timestamp >= cutoff);
|
|
106
|
-
}
|
|
107
|
-
async report() {
|
|
108
|
-
if (this.state !== 'running')
|
|
123
|
+
this.counter.add(level, now);
|
|
124
|
+
if (level !== 'error')
|
|
109
125
|
return;
|
|
110
|
-
|
|
126
|
+
const text = (0, buffer_js_1.cleanLogLine)(line);
|
|
127
|
+
this.lastError = { text, at: now };
|
|
128
|
+
if ((0, buffer_js_1.shouldEmitBurst)(this.lastBurstAt, now)) {
|
|
129
|
+
this.lastBurstAt = now;
|
|
130
|
+
this.burstLine = text;
|
|
131
|
+
this.collectNow();
|
|
132
|
+
}
|
|
111
133
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const warningCount = this.entries.filter((e) => e.type === 'warning').length;
|
|
122
|
-
const windowSeconds = this.windowMs / 1000;
|
|
123
|
-
let status = (0, thresholds_js_1.evaluateThreshold)(errorCount, {
|
|
124
|
-
warning: this.errorWarn,
|
|
125
|
-
critical: this.errorCrit,
|
|
126
|
-
direction: 'above',
|
|
127
|
-
});
|
|
128
|
-
if (overrideMessage)
|
|
134
|
+
async collect() {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const { errors, warnings } = this.counter.counts(now);
|
|
137
|
+
const window = formatWindow(this.windowMs / 1000);
|
|
138
|
+
const burst = this.burstLine;
|
|
139
|
+
this.burstLine = null;
|
|
140
|
+
let status = (0, thresholds_js_1.evaluateThreshold)(errors, this.errorThreshold);
|
|
141
|
+
// A burst report drives the bubble with the error itself and floors at warning.
|
|
142
|
+
if (burst)
|
|
129
143
|
status = (0, thresholds_js_1.worstStatus)([status, 'warning']);
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
const fileCount = `${this.files.length} file${this.files.length !== 1 ? 's' : ''}`;
|
|
145
|
+
const message = burst ??
|
|
146
|
+
(errors > 0
|
|
147
|
+
? `${errors} error${errors !== 1 ? 's' : ''} in last ${window} across ${fileCount}`
|
|
148
|
+
: `No errors in last ${window}`);
|
|
149
|
+
const lastErrorInWindow = this.lastError && now - this.lastError.at <= this.windowMs ? this.lastError.text : '';
|
|
150
|
+
return {
|
|
137
151
|
status,
|
|
138
|
-
mood,
|
|
152
|
+
mood: (0, thresholds_js_1.moodForStatus)(status),
|
|
139
153
|
message,
|
|
140
|
-
subStatus,
|
|
154
|
+
subStatus: burst ? 'error' : undefined,
|
|
141
155
|
screenData: (0, buffer_js_1.buildLogScreen)(this.recentLines),
|
|
142
156
|
metrics: {
|
|
143
|
-
error_count:
|
|
144
|
-
warning_count:
|
|
145
|
-
window_seconds:
|
|
157
|
+
error_count: errors,
|
|
158
|
+
warning_count: warnings,
|
|
159
|
+
window_seconds: this.windowMs / 1000,
|
|
146
160
|
files_watched: this.files.length,
|
|
147
|
-
last_error_message:
|
|
161
|
+
last_error_message: lastErrorInWindow,
|
|
148
162
|
},
|
|
149
163
|
};
|
|
150
|
-
try {
|
|
151
|
-
await this.pushMetrics(metrics);
|
|
152
|
-
}
|
|
153
|
-
catch (err) {
|
|
154
|
-
this.logger.error('Failed to push metrics', {
|
|
155
|
-
error: err instanceof Error ? err.message : String(err),
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
164
|
}
|
|
159
165
|
}
|
|
160
166
|
exports.LogWatcherNPC = LogWatcherNPC;
|
|
@@ -2,79 +2,126 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createTailer = createTailer;
|
|
4
4
|
const node_fs_1 = require("node:fs");
|
|
5
|
-
const
|
|
5
|
+
const node_string_decoder_1 = require("node:string_decoder");
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
pollMs: 1000,
|
|
8
|
+
maxLineChars: 16 * 1024,
|
|
9
|
+
maxReadBytes: 1024 * 1024,
|
|
10
|
+
maxBacklogBytes: 8 * 1024 * 1024,
|
|
11
|
+
};
|
|
12
|
+
function isNotFound(err) {
|
|
13
|
+
return err?.code === 'ENOENT';
|
|
14
|
+
}
|
|
6
15
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
16
|
+
* Follow a growing file like `tail -F`: emit each complete new line, starting
|
|
17
|
+
* from the end of the file as it is now (or from the start of a file created
|
|
18
|
+
* later).
|
|
19
|
+
*
|
|
20
|
+
* Polls with `stat` instead of `fs.watch`: watchers follow the inode, so they
|
|
21
|
+
* go deaf after logrotate's rename-and-recreate, and they throw for files that
|
|
22
|
+
* don't exist yet. Rotation (new inode) and truncation (size shrinks) restart
|
|
23
|
+
* from offset 0. Reads are bounded per poll, a partial last line is held until
|
|
24
|
+
* its newline arrives, over-long lines are cut, and a backlog beyond
|
|
25
|
+
* `maxBacklogBytes` (a runaway logger) is skipped so the watcher stays current.
|
|
9
26
|
*/
|
|
10
|
-
function createTailer(filePath, onLine) {
|
|
27
|
+
function createTailer(filePath, onLine, options = {}) {
|
|
28
|
+
const opts = { ...DEFAULTS, ...options };
|
|
11
29
|
let offset = 0;
|
|
12
|
-
let
|
|
13
|
-
let
|
|
30
|
+
let inode = null;
|
|
31
|
+
let partial = '';
|
|
32
|
+
let decoder = new node_string_decoder_1.StringDecoder('utf8');
|
|
33
|
+
let busy = false;
|
|
14
34
|
let closed = false;
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
/** False until the first successful stat (or ENOENT) fixes the starting offset. */
|
|
36
|
+
let started = false;
|
|
37
|
+
let lastError = '';
|
|
38
|
+
const reset = () => {
|
|
39
|
+
offset = 0;
|
|
40
|
+
partial = '';
|
|
41
|
+
decoder = new node_string_decoder_1.StringDecoder('utf8');
|
|
42
|
+
};
|
|
43
|
+
const reportError = (err) => {
|
|
44
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
45
|
+
if (error.message === lastError)
|
|
46
|
+
return;
|
|
47
|
+
lastError = error.message;
|
|
48
|
+
opts.onError?.(error);
|
|
49
|
+
};
|
|
50
|
+
const emitText = (text) => {
|
|
51
|
+
const pieces = (partial + text).split('\n');
|
|
52
|
+
partial = pieces.pop().slice(0, opts.maxLineChars);
|
|
53
|
+
for (const piece of pieces) {
|
|
54
|
+
if (closed)
|
|
55
|
+
return;
|
|
56
|
+
onLine(piece.replace(/\r$/, '').slice(0, opts.maxLineChars));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
async function readNew(size) {
|
|
60
|
+
if (size - offset > opts.maxBacklogBytes) {
|
|
61
|
+
// Too far behind: jump to the newest data rather than lag forever.
|
|
62
|
+
offset = size - opts.maxReadBytes;
|
|
63
|
+
partial = '';
|
|
64
|
+
decoder = new node_string_decoder_1.StringDecoder('utf8');
|
|
65
|
+
}
|
|
66
|
+
const length = Math.min(size - offset, opts.maxReadBytes);
|
|
67
|
+
const handle = await node_fs_1.promises.open(filePath, 'r');
|
|
17
68
|
try {
|
|
18
|
-
const
|
|
19
|
-
|
|
69
|
+
const buf = Buffer.alloc(length);
|
|
70
|
+
const { bytesRead } = await handle.read(buf, 0, length, offset);
|
|
71
|
+
offset += bytesRead;
|
|
72
|
+
emitText(decoder.write(buf.subarray(0, bytesRead)));
|
|
20
73
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
offset = 0;
|
|
74
|
+
finally {
|
|
75
|
+
await handle.close();
|
|
24
76
|
}
|
|
25
|
-
watcher = (0, node_fs_1.watch)(filePath, () => {
|
|
26
|
-
if (!closed) {
|
|
27
|
-
void readNewLines();
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
// Ignore watcher errors (e.g. file deleted then recreated)
|
|
31
|
-
watcher.on('error', () => { });
|
|
32
77
|
}
|
|
33
|
-
async function
|
|
34
|
-
if (
|
|
78
|
+
async function poll() {
|
|
79
|
+
if (busy || closed)
|
|
35
80
|
return;
|
|
36
|
-
|
|
81
|
+
busy = true;
|
|
37
82
|
try {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
reading = false;
|
|
83
|
+
const st = await node_fs_1.promises.stat(filePath);
|
|
84
|
+
if (!started) {
|
|
85
|
+
// Start at the current end of the file so only new lines are reported.
|
|
86
|
+
started = true;
|
|
87
|
+
inode = st.ino;
|
|
88
|
+
offset = st.size;
|
|
89
|
+
lastError = '';
|
|
46
90
|
return;
|
|
47
91
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
break;
|
|
57
|
-
// Account for line content + newline character
|
|
58
|
-
bytesRead += Buffer.byteLength(line, 'utf-8') + 1;
|
|
59
|
-
onLine(line);
|
|
60
|
-
}
|
|
61
|
-
offset += bytesRead;
|
|
92
|
+
if (inode !== null && st.ino !== inode)
|
|
93
|
+
reset(); // rotated: a new file took the name
|
|
94
|
+
inode = st.ino;
|
|
95
|
+
if (st.size < offset)
|
|
96
|
+
reset(); // truncated in place
|
|
97
|
+
if (st.size > offset)
|
|
98
|
+
await readNew(st.size);
|
|
99
|
+
lastError = '';
|
|
62
100
|
}
|
|
63
|
-
catch {
|
|
64
|
-
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (isNotFound(err)) {
|
|
103
|
+
started = true;
|
|
104
|
+
// Rotated away and not yet recreated (or never created): start the
|
|
105
|
+
// replacement from its first byte.
|
|
106
|
+
if (inode !== null)
|
|
107
|
+
reportError(new Error(`${filePath} disappeared; waiting for it`));
|
|
108
|
+
inode = null;
|
|
109
|
+
reset();
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
reportError(err);
|
|
113
|
+
}
|
|
65
114
|
}
|
|
66
115
|
finally {
|
|
67
|
-
|
|
116
|
+
busy = false;
|
|
68
117
|
}
|
|
69
118
|
}
|
|
70
|
-
void
|
|
119
|
+
const timer = setInterval(() => void poll(), opts.pollMs);
|
|
120
|
+
void poll();
|
|
71
121
|
return {
|
|
72
122
|
close() {
|
|
73
123
|
closed = true;
|
|
74
|
-
|
|
75
|
-
watcher.close();
|
|
76
|
-
watcher = null;
|
|
77
|
-
}
|
|
124
|
+
clearInterval(timer);
|
|
78
125
|
},
|
|
79
126
|
};
|
|
80
127
|
}
|
|
@@ -4,6 +4,7 @@ exports.ProcessWatcherNPC = 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 process_scanner_js_1 = require("./process-scanner.js");
|
|
8
9
|
const defaults_js_1 = require("./defaults.js");
|
|
9
10
|
/** Max per-process panels on the companion dashboard. */
|
|
@@ -15,14 +16,17 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
15
16
|
cpuHistories = new Map();
|
|
16
17
|
previousRunning = null;
|
|
17
18
|
constructor(config) {
|
|
18
|
-
|
|
19
|
-
throw new Error('process-watcher is not supported on Windows (relies on the POSIX `ps` command). ' +
|
|
20
|
-
'Run it under WSL/Linux, or use system-monitor instead.');
|
|
21
|
-
}
|
|
19
|
+
(0, errors_js_1.assertPosix)('process-watcher', 'It relies on the POSIX `ps` command; run it under WSL/Linux, or use system-monitor instead.');
|
|
22
20
|
super(config);
|
|
23
|
-
this.processes = config.processes;
|
|
21
|
+
this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
|
|
22
|
+
if (this.processes.length === 0) {
|
|
23
|
+
throw new errors_js_1.NpcConfigError('process-watcher needs at least one process name (--processes)');
|
|
24
|
+
}
|
|
24
25
|
this.cpuWarn = config.cpuWarn ?? defaults_js_1.DEFAULTS.cpuWarn;
|
|
25
26
|
this.memWarn = config.memWarn ?? defaults_js_1.DEFAULTS.memWarn;
|
|
27
|
+
if (!(this.cpuWarn > 0) || !(this.memWarn > 0)) {
|
|
28
|
+
throw new errors_js_1.NpcConfigError('--cpu-warn and --mem-warn must be positive numbers');
|
|
29
|
+
}
|
|
26
30
|
}
|
|
27
31
|
get name() {
|
|
28
32
|
return 'process-watcher';
|
|
@@ -33,9 +37,6 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
33
37
|
get archetype() {
|
|
34
38
|
return 'service';
|
|
35
39
|
}
|
|
36
|
-
get heartbeatSeconds() {
|
|
37
|
-
return 30;
|
|
38
|
-
}
|
|
39
40
|
get companion() {
|
|
40
41
|
return {
|
|
41
42
|
screenType: 'dashboard-wall',
|
|
@@ -45,22 +46,28 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
async collect() {
|
|
48
|
-
|
|
49
|
+
let infos;
|
|
50
|
+
try {
|
|
51
|
+
infos = await (0, process_scanner_js_1.scanProcesses)(this.processes);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
// Not knowing is different from "everything died": report unknown and
|
|
55
|
+
// leave the transition baseline alone so recovery doesn't say "is back".
|
|
56
|
+
return this.scanFailed(err);
|
|
57
|
+
}
|
|
49
58
|
const statuses = [];
|
|
50
|
-
const
|
|
59
|
+
const problems = [];
|
|
51
60
|
const metrics = {};
|
|
52
61
|
const panels = [];
|
|
53
62
|
let totalRunning = 0;
|
|
54
|
-
let totalMissing = 0;
|
|
55
63
|
for (const info of infos) {
|
|
56
64
|
const safeKey = info.name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
57
65
|
if (!info.running) {
|
|
58
66
|
statuses.push('critical');
|
|
59
|
-
|
|
67
|
+
problems.push(`${info.name} DOWN`);
|
|
60
68
|
metrics[`${safeKey}_running`] = 0;
|
|
61
69
|
metrics[`${safeKey}_cpu`] = 0;
|
|
62
70
|
metrics[`${safeKey}_mem_mb`] = 0;
|
|
63
|
-
totalMissing++;
|
|
64
71
|
if (panels.length < MAX_PANELS) {
|
|
65
72
|
panels.push({ label: info.name, value: 'DOWN', color: 'red' });
|
|
66
73
|
}
|
|
@@ -68,73 +75,76 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
68
75
|
}
|
|
69
76
|
totalRunning++;
|
|
70
77
|
const cpu = info.cpuPercent ?? 0;
|
|
78
|
+
const memMb = info.memMb ?? 0;
|
|
71
79
|
metrics[`${safeKey}_running`] = 1;
|
|
72
80
|
metrics[`${safeKey}_cpu`] = cpu;
|
|
73
|
-
metrics[`${safeKey}_mem_mb`] =
|
|
81
|
+
metrics[`${safeKey}_mem_mb`] = memMb;
|
|
74
82
|
const history = this.cpuHistories.get(info.name) ?? [];
|
|
75
83
|
(0, screen_js_1.pushHistory)(history, cpu);
|
|
76
84
|
this.cpuHistories.set(info.name, history);
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
statuses.push('healthy');
|
|
90
|
-
messageParts.push(`${info.name}: OK`);
|
|
85
|
+
// Single warning thresholds: no critical level for resource use.
|
|
86
|
+
const cpuStatus = (0, thresholds_js_1.evaluateThreshold)(cpu, { warning: this.cpuWarn, critical: Infinity });
|
|
87
|
+
const memStatus = (0, thresholds_js_1.evaluateThreshold)(memMb, { warning: this.memWarn, critical: Infinity });
|
|
88
|
+
const status = (0, thresholds_js_1.worstStatus)([cpuStatus, memStatus]);
|
|
89
|
+
statuses.push(status);
|
|
90
|
+
if (status !== 'healthy') {
|
|
91
|
+
const high = [];
|
|
92
|
+
if (cpuStatus !== 'healthy')
|
|
93
|
+
high.push(`cpu ${cpu}%`);
|
|
94
|
+
if (memStatus !== 'healthy')
|
|
95
|
+
high.push(`mem ${Math.round(memMb)}MB`);
|
|
96
|
+
problems.push(`${info.name} high ${high.join(', ')}`);
|
|
91
97
|
}
|
|
92
98
|
if (panels.length < MAX_PANELS) {
|
|
93
99
|
panels.push({
|
|
94
100
|
label: info.name,
|
|
95
101
|
value: `${cpu}%`,
|
|
96
|
-
color:
|
|
102
|
+
color: (0, screen_js_1.statusColor)(status),
|
|
97
103
|
sparkline: [...history],
|
|
98
104
|
});
|
|
99
105
|
}
|
|
100
106
|
}
|
|
107
|
+
const totalMissing = infos.length - totalRunning;
|
|
101
108
|
metrics['total_watched'] = this.processes.length;
|
|
102
109
|
metrics['total_running'] = totalRunning;
|
|
103
110
|
metrics['total_missing'] = totalMissing;
|
|
104
111
|
// Transition speech: compare the running set to last poll's. On the first
|
|
105
112
|
// poll we only seed the set (no bogus "is back" for already-running procs).
|
|
106
113
|
const currentRunning = new Set(infos.filter((i) => i.running).map((i) => i.name));
|
|
107
|
-
|
|
108
|
-
if (this.previousRunning !== null) {
|
|
109
|
-
transitions = (0, process_scanner_js_1.detectTransitions)(this.previousRunning, currentRunning);
|
|
110
|
-
}
|
|
114
|
+
const transitions = this.previousRunning === null ? [] : (0, process_scanner_js_1.detectTransitions)(this.previousRunning, currentRunning);
|
|
111
115
|
this.previousRunning = currentRunning;
|
|
112
116
|
const status = (0, thresholds_js_1.worstStatus)(statuses);
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
const summary = problems.length === 0
|
|
118
|
+
? `All ${infos.length} running`
|
|
119
|
+
: `${totalRunning}/${infos.length} running · ${problems.join(' · ')}`;
|
|
120
|
+
// A death/return takes over the bubble this cycle so it reads as an alert.
|
|
121
|
+
const message = transitions.length > 0 ? transitions.join(' · ') : summary;
|
|
117
122
|
return {
|
|
118
123
|
status,
|
|
119
|
-
mood,
|
|
124
|
+
mood: (0, thresholds_js_1.moodForStatus)(status),
|
|
120
125
|
message,
|
|
121
126
|
subStatus: totalMissing > 0 ? 'process-down' : undefined,
|
|
122
|
-
screenData,
|
|
127
|
+
screenData: { layout: 'multi-metric', values: panels },
|
|
123
128
|
metrics,
|
|
124
129
|
};
|
|
125
130
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
scanFailed(err) {
|
|
132
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
133
|
+
this.logger.warn('Cannot list processes', { error: reason });
|
|
134
|
+
return {
|
|
135
|
+
status: 'unknown',
|
|
136
|
+
mood: (0, thresholds_js_1.moodForStatus)('unknown'),
|
|
137
|
+
message: 'Cannot list processes (ps failed)',
|
|
138
|
+
subStatus: 'scan-failed',
|
|
139
|
+
screenData: {
|
|
140
|
+
layout: 'multi-metric',
|
|
141
|
+
values: this.processes
|
|
142
|
+
.slice(0, MAX_PANELS)
|
|
143
|
+
.map((name) => ({ label: name, value: '?', color: 'gray' })),
|
|
144
|
+
},
|
|
145
|
+
metrics: { total_watched: this.processes.length },
|
|
146
|
+
};
|
|
138
147
|
}
|
|
139
148
|
}
|
|
149
|
+
exports.ProcessWatcherNPC = ProcessWatcherNPC;
|
|
140
150
|
//# sourceMappingURL=index.js.map
|