@nocville/npc 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +55 -17
  2. package/dist/cjs/cli.js +94 -39
  3. package/dist/cjs/core/base-npc.js +15 -0
  4. package/dist/cjs/core/config.js +19 -2
  5. package/dist/cjs/core/errors.js +23 -0
  6. package/dist/cjs/core/http.js +121 -0
  7. package/dist/cjs/core/pixel-art.js +110 -0
  8. package/dist/cjs/core/polling-npc.js +99 -14
  9. package/dist/cjs/core/text.js +13 -0
  10. package/dist/cjs/core/thresholds.js +29 -0
  11. package/dist/cjs/index.js +21 -2
  12. package/dist/cjs/npcs/agent-common/session.js +1 -0
  13. package/dist/cjs/npcs/agent-common/shim.js +8 -2
  14. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  15. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  16. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  17. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  18. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  19. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  20. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  21. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  22. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  23. package/dist/cjs/npcs/office-characters/index.js +92 -0
  24. package/dist/cjs/npcs/process-watcher/index.js +60 -51
  25. package/dist/cjs/npcs/process-watcher/process-scanner.js +100 -68
  26. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  27. package/dist/cjs/npcs/receptionist/index.js +6 -93
  28. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  29. package/dist/cjs/npcs/security/index.js +26 -0
  30. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  31. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  32. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  33. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  34. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  35. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  36. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  37. package/dist/cli.js +95 -40
  38. package/dist/core/base-npc.d.ts +5 -0
  39. package/dist/core/base-npc.js +15 -0
  40. package/dist/core/config.d.ts +5 -0
  41. package/dist/core/config.js +18 -2
  42. package/dist/core/errors.d.ts +11 -0
  43. package/dist/core/errors.js +18 -0
  44. package/dist/core/http.d.ts +52 -0
  45. package/dist/core/http.js +111 -0
  46. package/dist/core/pixel-art.d.ts +38 -0
  47. package/dist/core/pixel-art.js +105 -0
  48. package/dist/core/polling-npc.d.ts +22 -1
  49. package/dist/core/polling-npc.js +98 -13
  50. package/dist/core/text.d.ts +6 -0
  51. package/dist/core/text.js +12 -0
  52. package/dist/core/thresholds.d.ts +9 -1
  53. package/dist/core/thresholds.js +27 -0
  54. package/dist/core/types.d.ts +5 -0
  55. package/dist/index.d.ts +7 -2
  56. package/dist/index.js +6 -2
  57. package/dist/npcs/agent-common/session.js +1 -0
  58. package/dist/npcs/agent-common/shim.js +8 -2
  59. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  60. package/dist/npcs/github-watcher/defaults.js +7 -1
  61. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  62. package/dist/npcs/github-watcher/github-api.js +80 -45
  63. package/dist/npcs/github-watcher/index.d.ts +6 -0
  64. package/dist/npcs/github-watcher/index.js +83 -37
  65. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  66. package/dist/npcs/internet-connectivity/checks.js +70 -67
  67. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  68. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  69. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  70. package/dist/npcs/internet-connectivity/index.js +135 -133
  71. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  72. package/dist/npcs/log-watcher/buffer.js +58 -0
  73. package/dist/npcs/log-watcher/index.d.ts +22 -21
  74. package/dist/npcs/log-watcher/index.js +112 -106
  75. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  76. package/dist/npcs/log-watcher/tail.js +101 -54
  77. package/dist/npcs/office-characters/index.d.ts +50 -0
  78. package/dist/npcs/office-characters/index.js +82 -0
  79. package/dist/npcs/process-watcher/index.d.ts +1 -1
  80. package/dist/npcs/process-watcher/index.js +61 -52
  81. package/dist/npcs/process-watcher/process-scanner.d.ts +31 -1
  82. package/dist/npcs/process-watcher/process-scanner.js +97 -70
  83. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  84. package/dist/npcs/receptionist/defaults.js +4 -0
  85. package/dist/npcs/receptionist/index.d.ts +3 -12
  86. package/dist/npcs/receptionist/index.js +6 -93
  87. package/dist/npcs/receptionist/weather.d.ts +8 -6
  88. package/dist/npcs/receptionist/weather.js +39 -45
  89. package/dist/npcs/security/index.d.ts +12 -0
  90. package/dist/npcs/security/index.js +22 -0
  91. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  92. package/dist/npcs/system-monitor/collectors.js +64 -17
  93. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  94. package/dist/npcs/system-monitor/index.d.ts +2 -1
  95. package/dist/npcs/system-monitor/index.js +54 -30
  96. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  97. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  98. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  99. package/dist/npcs/web-page-watcher/capture.js +21 -1
  100. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  101. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  102. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  103. package/dist/npcs/web-page-watcher/index.js +121 -70
  104. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  105. package/package.json +2 -2
@@ -1,84 +1,87 @@
1
1
  import { promises as dns } from 'node:dns';
2
2
  import net from 'node:net';
3
- export async function checkDns(hosts) {
4
- return Promise.all(hosts.map(resolveSingleDns));
3
+ import { NPC_USER_AGENT, discardBody, displayUrl } from '../../core/http.js';
4
+ function errorText(err) {
5
+ return err instanceof Error ? err.message : String(err);
5
6
  }
6
- async function resolveSingleDns(host) {
7
- const start = performance.now();
8
- try {
9
- await dns.resolve(host);
10
- return { host, ok: true, timeMs: performance.now() - start };
11
- }
12
- catch (err) {
13
- return {
14
- host,
15
- ok: false,
16
- timeMs: performance.now() - start,
17
- error: err instanceof Error ? err.message : String(err),
18
- };
19
- }
20
- }
21
- export async function checkHttp(urls, timeout) {
22
- return Promise.all(urls.map((url) => fetchSingleUrl(url, timeout)));
23
- }
24
- async function fetchSingleUrl(url, timeout) {
25
- const controller = new AbortController();
26
- const timer = setTimeout(() => controller.abort(), timeout);
27
- const start = performance.now();
7
+ // ---------- DNS ----------
8
+ /**
9
+ * Resolve each host's A records through a dedicated resolver with a bounded
10
+ * timeout (the default resolver retries for tens of seconds when DNS is down).
11
+ */
12
+ export async function checkDns(hosts, timeoutMs) {
13
+ const resolver = new dns.Resolver({
14
+ timeout: Math.max(250, Math.floor(timeoutMs / 2)),
15
+ tries: 2,
16
+ });
17
+ let timer;
18
+ const deadline = new Promise((resolve) => {
19
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
20
+ });
28
21
  try {
29
- const response = await fetch(url, { signal: controller.signal });
30
- // Consume body to ensure connection is completed
31
- await response.text();
32
- return {
33
- url,
34
- ok: response.ok,
35
- timeMs: performance.now() - start,
36
- statusCode: response.status,
37
- };
38
- }
39
- catch (err) {
40
- return {
41
- url,
42
- ok: false,
43
- timeMs: performance.now() - start,
44
- error: err instanceof Error ? err.message : String(err),
45
- };
22
+ return await Promise.all(hosts.map(async (host) => {
23
+ const start = performance.now();
24
+ try {
25
+ const outcome = await Promise.race([resolver.resolve4(host), deadline]);
26
+ if (outcome === 'timeout')
27
+ return { target: host, ok: false, timeMs: 0, error: 'timeout' };
28
+ return { target: host, ok: true, timeMs: performance.now() - start };
29
+ }
30
+ catch (err) {
31
+ return { target: host, ok: false, timeMs: 0, error: errorText(err) };
32
+ }
33
+ }));
46
34
  }
47
35
  finally {
48
36
  clearTimeout(timer);
37
+ resolver.cancel();
49
38
  }
50
39
  }
51
- export async function checkLatency(hosts) {
52
- return Promise.all(hosts.map(measureTcpConnect));
40
+ // ---------- HTTP ----------
41
+ /**
42
+ * Time to response headers for each URL. The body is discarded unread, so a
43
+ * large or endless response costs nothing. Only 2xx counts as reachable.
44
+ */
45
+ export async function checkHttp(urls, timeoutMs) {
46
+ return Promise.all(urls.map(async (url) => {
47
+ const target = displayUrl(url); // never echo credentials or query tokens
48
+ const start = performance.now();
49
+ try {
50
+ const res = await fetch(url, {
51
+ signal: AbortSignal.timeout(timeoutMs),
52
+ headers: { 'User-Agent': NPC_USER_AGENT },
53
+ });
54
+ const timeMs = performance.now() - start;
55
+ await discardBody(res);
56
+ return res.ok
57
+ ? { target, ok: true, timeMs }
58
+ : { target, ok: false, timeMs: 0, error: `HTTP ${res.status}` };
59
+ }
60
+ catch (err) {
61
+ return { target, ok: false, timeMs: 0, error: errorText(err) };
62
+ }
63
+ }));
64
+ }
65
+ // ---------- Latency (TCP connect) ----------
66
+ /** Time to establish a TCP connection to each host:port. */
67
+ export async function checkLatency(hosts, timeoutMs) {
68
+ return Promise.all(hosts.map((h) => measureTcpConnect(h, timeoutMs)));
53
69
  }
54
- function measureTcpConnect(target) {
70
+ function measureTcpConnect(target, timeoutMs) {
71
+ const label = target.host.includes(':')
72
+ ? `[${target.host}]:${target.port}`
73
+ : `${target.host}:${target.port}`;
55
74
  return new Promise((resolve) => {
56
75
  const start = performance.now();
57
76
  const socket = net.connect({ host: target.host, port: target.port });
58
- const timeout = setTimeout(() => {
77
+ const finish = (result) => {
78
+ clearTimeout(timer);
59
79
  socket.destroy();
60
- resolve({
61
- host: target.host,
62
- ok: false,
63
- timeMs: performance.now() - start,
64
- error: 'Connection timed out',
65
- });
66
- }, 5000);
67
- socket.on('connect', () => {
68
- clearTimeout(timeout);
69
- const timeMs = performance.now() - start;
70
- socket.destroy();
71
- resolve({ host: target.host, ok: true, timeMs });
72
- });
73
- socket.on('error', (err) => {
74
- clearTimeout(timeout);
75
- resolve({
76
- host: target.host,
77
- ok: false,
78
- timeMs: performance.now() - start,
79
- error: err.message,
80
- });
81
- });
80
+ resolve(result);
81
+ };
82
+ const timer = setTimeout(() => finish({ target: label, ok: false, timeMs: 0, error: 'Connection timed out' }), timeoutMs);
83
+ socket.once('connect', () => finish({ target: label, ok: true, timeMs: performance.now() - start }));
84
+ socket.once('error', (err) => finish({ target: label, ok: false, timeMs: 0, error: err.message }));
82
85
  });
83
86
  }
84
87
  //# sourceMappingURL=checks.js.map
@@ -8,7 +8,10 @@ export interface InternetConnectivityDefaults {
8
8
  dnsHosts: string[];
9
9
  httpUrls: string[];
10
10
  latencyHosts: LatencyHost[];
11
+ /** Port for a `--latency-hosts` entry given without one. */
12
+ latencyPort: number;
11
13
  slowThreshold: number;
14
+ /** Per-check timeout (DNS, HTTP and TCP) in ms. */
12
15
  httpTimeout: number;
13
16
  thresholds: {
14
17
  dnsTime: ThresholdConfig;
@@ -4,10 +4,13 @@ export const DEFAULTS = {
4
4
  // gstatic generate_204 is a stable, low-latency 204 endpoint used by many
5
5
  // OSes for connectivity checks (replaces the flaky httpbin.org default).
6
6
  httpUrls: ['https://1.1.1.1/cdn-cgi/trace', 'https://www.gstatic.com/generate_204'],
7
+ // 443, not 80: Google's 8.8.8.8 doesn't listen on port 80, so that default
8
+ // timed out on every poll and pinned the NPC at warning.
7
9
  latencyHosts: [
8
- { host: '8.8.8.8', port: 80 },
9
- { host: '1.1.1.1', port: 80 },
10
+ { host: '8.8.8.8', port: 443 },
11
+ { host: '1.1.1.1', port: 443 },
10
12
  ],
13
+ latencyPort: 80,
11
14
  slowThreshold: 1000, // ms
12
15
  httpTimeout: 5000, // ms
13
16
  thresholds: {
@@ -10,17 +10,20 @@ export interface ConnectivityThresholds {
10
10
  export interface InternetConnectivityConfig extends PollingNpcConfig {
11
11
  dnsHosts?: string[];
12
12
  httpUrls?: string[];
13
+ /** `{host, port}` pairs, or "host" / "host:port" / "[v6]:port" strings. */
13
14
  latencyHosts?: LatencyHost[] | string[];
14
15
  slowThreshold?: number;
16
+ /** Per-check timeout in ms (DNS, HTTP and TCP). */
15
17
  httpTimeout?: number;
16
18
  thresholds?: ConnectivityThresholds;
17
19
  }
18
20
  interface ConnectivitySample {
19
21
  allFailed: boolean;
20
22
  anyFailed: boolean;
21
- dnsAvg: number;
22
- httpAvg: number;
23
- latencyAvg: number;
23
+ /** Mean time of the successful checks of each kind; null when none succeeded. */
24
+ dnsAvg: number | null;
25
+ httpAvg: number | null;
26
+ latencyAvg: number | null;
24
27
  }
25
28
  /**
26
29
  * Derive overall status from live per-check thresholds. All checks failing is
@@ -28,6 +31,11 @@ interface ConnectivitySample {
28
31
  * escalate per their own thresholds.
29
32
  */
30
33
  export declare function evaluateConnectivityStatus(sample: ConnectivitySample, thresholds: ConnectivityThresholds): Status;
34
+ /**
35
+ * Parse a latency target: "host", "host:port", "[ipv6]:port" or a bare IPv6
36
+ * address. Throws NpcConfigError on an out-of-range port.
37
+ */
38
+ export declare function parseLatencyHost(input: string, defaultPort?: number): LatencyHost;
31
39
  export declare class InternetConnectivityNPC extends PollingNPC {
32
40
  private dnsHosts;
33
41
  private httpUrls;
@@ -44,10 +52,15 @@ export declare class InternetConnectivityNPC extends PollingNPC {
44
52
  get behavior(): BehaviorHints;
45
53
  get companion(): CompanionDecl;
46
54
  protected collect(): Promise<CollectedMetrics>;
47
- private buildOnlineMessage;
48
- private buildOfflineMessage;
49
55
  /**
50
- * Build config from CLI flags. Merges with any programmatic overrides.
56
+ * One dashboard panel. A category whose checks all failed shows FAIL in red
57
+ * (and a 0 sparkline sample) rather than a misleading latency; a partial
58
+ * failure is amber.
59
+ */
60
+ private panel;
61
+ /**
62
+ * Build config from CLI flags. List flags are comma-separated; latency
63
+ * hosts accept "host", "host:port" or "[ipv6]:port".
51
64
  */
52
65
  static configFromFlags(flags: Record<string, string | boolean>): Partial<InternetConnectivityConfig>;
53
66
  }
@@ -1,7 +1,9 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
- import { evaluateThreshold, worstStatus } from '../../core/thresholds.js';
2
+ import { evaluateThreshold, worstStatus, moodForStatus } from '../../core/thresholds.js';
3
3
  import { statusColor, pushHistory } from '../../core/screen.js';
4
- import { numberFlag, stringFlag } from '../../core/config.js';
4
+ import { listFlag, numberFlag } from '../../core/config.js';
5
+ import { NpcConfigError } from '../../core/errors.js';
6
+ import { isHttpUrl } from '../../core/http.js';
5
7
  import { checkDns, checkHttp, checkLatency } from './checks.js';
6
8
  import { DEFAULTS } from './defaults.js';
7
9
  /**
@@ -12,13 +14,15 @@ import { DEFAULTS } from './defaults.js';
12
14
  export function evaluateConnectivityStatus(sample, thresholds) {
13
15
  if (sample.allFailed)
14
16
  return 'critical';
15
- const timeStatus = worstStatus([
16
- evaluateThreshold(sample.dnsAvg, thresholds.dnsTime),
17
- evaluateThreshold(sample.httpAvg, thresholds.httpTime),
18
- evaluateThreshold(sample.latencyAvg, thresholds.latencyTime),
19
- ]);
20
- const failStatus = sample.anyFailed ? 'warning' : 'healthy';
21
- return worstStatus([timeStatus, failStatus]);
17
+ const statuses = [sample.anyFailed ? 'warning' : 'healthy'];
18
+ if (sample.dnsAvg !== null)
19
+ statuses.push(evaluateThreshold(sample.dnsAvg, thresholds.dnsTime));
20
+ if (sample.httpAvg !== null)
21
+ statuses.push(evaluateThreshold(sample.httpAvg, thresholds.httpTime));
22
+ if (sample.latencyAvg !== null) {
23
+ statuses.push(evaluateThreshold(sample.latencyAvg, thresholds.latencyTime));
24
+ }
25
+ return worstStatus(statuses);
22
26
  }
23
27
  /** Scale a threshold set by a ratio, preserving direction. */
24
28
  function scaleThresholds(base, ratio) {
@@ -33,6 +37,30 @@ function scaleThresholds(base, ratio) {
33
37
  latencyTime: scale(base.latencyTime),
34
38
  };
35
39
  }
40
+ /**
41
+ * Parse a latency target: "host", "host:port", "[ipv6]:port" or a bare IPv6
42
+ * address. Throws NpcConfigError on an out-of-range port.
43
+ */
44
+ export function parseLatencyHost(input, defaultPort = DEFAULTS.latencyPort) {
45
+ const s = input.trim();
46
+ const toPort = (raw) => {
47
+ const port = Number(raw);
48
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
49
+ throw new NpcConfigError(`Invalid port in latency host "${s}"`);
50
+ }
51
+ return port;
52
+ };
53
+ const bracketed = s.match(/^\[([^\]]+)\](?::(\d*))?$/);
54
+ if (bracketed) {
55
+ return { host: bracketed[1], port: bracketed[2] ? toPort(bracketed[2]) : defaultPort };
56
+ }
57
+ const colons = s.split(':').length - 1;
58
+ if (colons === 1) {
59
+ const [host, port] = s.split(':');
60
+ return { host, port: toPort(port) };
61
+ }
62
+ return { host: s, port: defaultPort }; // plain host, or a bare IPv6 address
63
+ }
36
64
  export class InternetConnectivityNPC extends PollingNPC {
37
65
  dnsHosts;
38
66
  httpUrls;
@@ -49,10 +77,16 @@ export class InternetConnectivityNPC extends PollingNPC {
49
77
  });
50
78
  this.dnsHosts = config.dnsHosts ?? DEFAULTS.dnsHosts;
51
79
  this.httpUrls = config.httpUrls ?? DEFAULTS.httpUrls;
52
- this.latencyHosts = config.latencyHosts
53
- ? parseLatencyHosts(config.latencyHosts)
54
- : DEFAULTS.latencyHosts;
55
- this.httpTimeout = config.httpTimeout ?? DEFAULTS.httpTimeout;
80
+ this.latencyHosts = (config.latencyHosts ?? DEFAULTS.latencyHosts).map((h) => typeof h === 'string' ? parseLatencyHost(h) : h);
81
+ // Every check must finish well inside the poll interval.
82
+ this.httpTimeout = Math.min(Math.max(500, config.httpTimeout ?? DEFAULTS.httpTimeout), this.pollInterval / 2);
83
+ const badUrl = this.httpUrls.find((u) => !isHttpUrl(u));
84
+ if (badUrl !== undefined) {
85
+ throw new NpcConfigError(`--http-urls: "${badUrl}" is not an http(s) URL`);
86
+ }
87
+ if (this.dnsHosts.length === 0 && this.httpUrls.length === 0) {
88
+ throw new NpcConfigError('internet-connectivity needs at least one DNS host or HTTP URL');
89
+ }
56
90
  // Threshold resolution: explicit thresholds win; else if the legacy
57
91
  // --slow-threshold differs from the default, scale the live thresholds by
58
92
  // that ratio for back-compat; else use the defaults.
@@ -88,115 +122,91 @@ export class InternetConnectivityNPC extends PollingNPC {
88
122
  }
89
123
  async collect() {
90
124
  const [dnsResults, httpResults, latencyResults] = await Promise.all([
91
- checkDns(this.dnsHosts),
125
+ checkDns(this.dnsHosts, this.httpTimeout),
92
126
  checkHttp(this.httpUrls, this.httpTimeout),
93
- checkLatency(this.latencyHosts),
127
+ checkLatency(this.latencyHosts, this.httpTimeout),
94
128
  ]);
95
- const dnsOk = dnsResults.filter((r) => r.ok).length;
96
- const dnsFail = dnsResults.length - dnsOk;
97
- const httpOk = httpResults.filter((r) => r.ok).length;
98
- const httpFail = httpResults.length - httpOk;
99
- const dnsAvg = avgTime(dnsResults);
100
- const httpAvg = avgTime(httpResults);
101
- const latencyAvg = avgTime(latencyResults);
129
+ const dnsOk = countOk(dnsResults);
130
+ const httpOk = countOk(httpResults);
131
+ const latencyOk = countOk(latencyResults);
132
+ const dnsAvg = avgOkTime(dnsResults);
133
+ const httpAvg = avgOkTime(httpResults);
134
+ const latencyAvg = avgOkTime(latencyResults);
102
135
  const isOnline = dnsOk > 0 || httpOk > 0;
103
- const allFailed = dnsOk === 0 && httpOk === 0;
104
- const anyFailed = dnsResults.some((r) => !r.ok) ||
105
- httpResults.some((r) => !r.ok) ||
106
- latencyResults.some((r) => !r.ok);
107
- const status = evaluateConnectivityStatus({ allFailed, anyFailed, dnsAvg, httpAvg, latencyAvg }, this.thresholds);
108
- const mood = statusToMood(status);
109
- const message = allFailed
110
- ? this.buildOfflineMessage(dnsResults, httpResults)
111
- : this.buildOnlineMessage(dnsAvg, httpAvg, latencyAvg);
112
- pushHistory(this.dnsHistory, round(dnsAvg));
113
- pushHistory(this.httpHistory, round(httpAvg));
114
- pushHistory(this.latencyHistory, round(latencyAvg));
115
- const screenData = {
116
- layout: 'multi-metric',
117
- values: [
118
- {
119
- label: 'DNS',
120
- value: `${round(dnsAvg)}ms`,
121
- color: statusColor(evaluateThreshold(dnsAvg, this.thresholds.dnsTime)),
122
- sparkline: [...this.dnsHistory],
123
- },
124
- {
125
- label: 'HTTP',
126
- value: `${round(httpAvg)}ms`,
127
- color: statusColor(evaluateThreshold(httpAvg, this.thresholds.httpTime)),
128
- sparkline: [...this.httpHistory],
129
- },
130
- {
131
- label: 'TCP',
132
- value: `${round(latencyAvg)}ms`,
133
- color: statusColor(evaluateThreshold(latencyAvg, this.thresholds.latencyTime)),
134
- sparkline: [...this.latencyHistory],
135
- },
136
- { label: 'NET', value: isOnline ? 'ONLINE' : 'OFFLINE', color: isOnline ? 'green' : 'red' },
137
- ],
138
- };
136
+ const anyFailed = dnsOk < dnsResults.length || httpOk < httpResults.length || latencyOk < latencyResults.length;
137
+ const status = evaluateConnectivityStatus({ allFailed: !isOnline, anyFailed, dnsAvg, httpAvg, latencyAvg }, this.thresholds);
138
+ const message = isOnline
139
+ ? `Online | DNS: ${fmtMs(dnsAvg)} | HTTP: ${fmtMs(httpAvg)} | Latency: ${fmtMs(latencyAvg)}` +
140
+ (anyFailed ? ` | ${failureSummary(dnsResults, httpResults, latencyResults)}` : '')
141
+ : offlineMessage(dnsResults, httpResults);
139
142
  return {
140
143
  status,
141
- mood,
144
+ mood: moodForStatus(status),
142
145
  message,
143
146
  subStatus: isOnline ? undefined : 'offline',
144
- screenData,
147
+ screenData: {
148
+ layout: 'multi-metric',
149
+ values: [
150
+ this.panel('DNS', dnsResults, dnsAvg, this.dnsHistory, this.thresholds.dnsTime),
151
+ this.panel('HTTP', httpResults, httpAvg, this.httpHistory, this.thresholds.httpTime),
152
+ this.panel('TCP', latencyResults, latencyAvg, this.latencyHistory, this.thresholds.latencyTime),
153
+ {
154
+ label: 'NET',
155
+ value: isOnline ? 'ONLINE' : 'OFFLINE',
156
+ color: isOnline ? 'green' : 'red',
157
+ },
158
+ ],
159
+ },
145
160
  metrics: {
146
- dns_avg_ms: round(dnsAvg),
147
- http_avg_ms: round(httpAvg),
148
- latency_avg_ms: round(latencyAvg),
161
+ dns_avg_ms: round(dnsAvg ?? 0),
162
+ http_avg_ms: round(httpAvg ?? 0),
163
+ latency_avg_ms: round(latencyAvg ?? 0),
149
164
  dns_ok_count: dnsOk,
150
- dns_fail_count: dnsFail,
165
+ dns_fail_count: dnsResults.length - dnsOk,
151
166
  http_ok_count: httpOk,
152
- http_fail_count: httpFail,
167
+ http_fail_count: httpResults.length - httpOk,
168
+ latency_ok_count: latencyOk,
169
+ latency_fail_count: latencyResults.length - latencyOk,
153
170
  is_online: isOnline ? 1 : 0,
154
171
  },
155
172
  };
156
173
  }
157
- buildOnlineMessage(dnsAvg, httpAvg, latencyAvg) {
158
- return `Online | DNS: ${round(dnsAvg)}ms | HTTP: ${round(httpAvg)}ms | Latency: ${round(latencyAvg)}ms`;
159
- }
160
- buildOfflineMessage(dnsResults, httpResults) {
161
- const problems = [];
162
- if (dnsResults.every((r) => !r.ok)) {
163
- problems.push('DNS resolution failed');
164
- }
165
- if (httpResults.every((r) => !r.ok)) {
166
- problems.push('no HTTP connectivity');
167
- }
168
- return `OFFLINE: ${problems.join(', ') || 'all checks failed'}`;
174
+ /**
175
+ * One dashboard panel. A category whose checks all failed shows FAIL in red
176
+ * (and a 0 sparkline sample) rather than a misleading latency; a partial
177
+ * failure is amber.
178
+ */
179
+ panel(label, results, avg, history, threshold) {
180
+ if (results.length === 0)
181
+ return { label, value: 'off', color: 'gray' };
182
+ pushHistory(history, round(avg ?? 0));
183
+ const sparkline = [...history];
184
+ if (avg === null)
185
+ return { label, value: 'FAIL', color: 'red', sparkline };
186
+ const color = countOk(results) < results.length ? 'amber' : statusColor(evaluateThreshold(avg, threshold));
187
+ return { label, value: fmtMs(avg), color, sparkline };
169
188
  }
170
189
  /**
171
- * Build config from CLI flags. Merges with any programmatic overrides.
190
+ * Build config from CLI flags. List flags are comma-separated; latency
191
+ * hosts accept "host", "host:port" or "[ipv6]:port".
172
192
  */
173
193
  static configFromFlags(flags) {
174
194
  const config = {};
175
- const dnsHostsRaw = stringFlag(flags, 'dns-hosts');
176
- if (dnsHostsRaw) {
177
- config.dnsHosts = dnsHostsRaw.split(',').map((s) => s.trim());
178
- }
179
- const httpUrlsRaw = stringFlag(flags, 'http-urls');
180
- if (httpUrlsRaw) {
181
- config.httpUrls = httpUrlsRaw.split(',').map((s) => s.trim());
182
- }
183
- const latencyHostsRaw = stringFlag(flags, 'latency-hosts');
184
- if (latencyHostsRaw) {
185
- config.latencyHosts = latencyHostsRaw.split(',').map((s) => {
186
- const trimmed = s.trim();
187
- const colonIndex = trimmed.lastIndexOf(':');
188
- if (colonIndex !== -1) {
189
- const host = trimmed.slice(0, colonIndex);
190
- const port = Number(trimmed.slice(colonIndex + 1));
191
- if (!Number.isNaN(port))
192
- return { host, port };
193
- }
194
- return { host: trimmed, port: 80 };
195
- });
196
- }
195
+ const dnsHosts = listFlag(flags, 'dns-hosts');
196
+ if (dnsHosts)
197
+ config.dnsHosts = dnsHosts;
198
+ const httpUrls = listFlag(flags, 'http-urls');
199
+ if (httpUrls)
200
+ config.httpUrls = httpUrls;
201
+ const latencyHosts = listFlag(flags, 'latency-hosts');
202
+ if (latencyHosts)
203
+ config.latencyHosts = latencyHosts.map((h) => parseLatencyHost(h));
197
204
  const slowThreshold = numberFlag(flags, 'slow-threshold');
198
205
  if (slowThreshold !== undefined)
199
206
  config.slowThreshold = slowThreshold;
207
+ const timeout = numberFlag(flags, 'timeout');
208
+ if (timeout !== undefined)
209
+ config.httpTimeout = timeout;
200
210
  const pollInterval = numberFlag(flags, 'poll-interval');
201
211
  if (pollInterval !== undefined)
202
212
  config.pollInterval = pollInterval;
@@ -204,43 +214,35 @@ export class InternetConnectivityNPC extends PollingNPC {
204
214
  }
205
215
  }
206
216
  // ---------- Helpers ----------
207
- function avgTime(results) {
208
- if (results.length === 0)
209
- return 0;
210
- const sum = results.reduce((acc, r) => acc + r.timeMs, 0);
211
- return sum / results.length;
217
+ function countOk(results) {
218
+ return results.filter((r) => r.ok).length;
219
+ }
220
+ /** Mean time of the successful checks only — a 5s timeout is a failure, not a latency. */
221
+ function avgOkTime(results) {
222
+ const ok = results.filter((r) => r.ok);
223
+ if (ok.length === 0)
224
+ return null;
225
+ return ok.reduce((acc, r) => acc + r.timeMs, 0) / ok.length;
212
226
  }
213
227
  function round(n) {
214
228
  return Math.round(n * 100) / 100;
215
229
  }
216
- function statusToMood(status) {
217
- switch (status) {
218
- case 'healthy':
219
- return 'happy';
220
- case 'warning':
221
- return 'concerned';
222
- case 'critical':
223
- return 'stressed';
224
- default:
225
- return 'idle';
226
- }
230
+ function fmtMs(n) {
231
+ return n === null ? 'FAIL' : `${Math.round(n)}ms`;
227
232
  }
228
- /**
229
- * Normalise latency hosts from either string[] (CLI) or LatencyHost[].
230
- * String entries can be "host" or "host:port".
231
- */
232
- function parseLatencyHosts(input) {
233
- return input.map((item) => {
234
- if (typeof item === 'object')
235
- return item;
236
- const colonIndex = item.lastIndexOf(':');
237
- if (colonIndex !== -1) {
238
- const host = item.slice(0, colonIndex);
239
- const port = Number(item.slice(colonIndex + 1));
240
- if (!Number.isNaN(port))
241
- return { host, port };
242
- }
243
- return { host: item, port: 80 };
244
- });
233
+ /** "failing: 8.8.8.8:443, dns.example" — names the targets, never error text. */
234
+ function failureSummary(...groups) {
235
+ const failed = groups.flat().filter((r) => !r.ok);
236
+ return `failing: ${failed.map((r) => r.target).join(', ')}`;
237
+ }
238
+ function offlineMessage(dnsResults, httpResults) {
239
+ const problems = [];
240
+ if (dnsResults.length > 0 && dnsResults.every((r) => !r.ok)) {
241
+ problems.push('DNS resolution failed');
242
+ }
243
+ if (httpResults.length > 0 && httpResults.every((r) => !r.ok)) {
244
+ problems.push('no HTTP connectivity');
245
+ }
246
+ return `OFFLINE: ${problems.join(', ') || 'all checks failed'}`;
245
247
  }
246
248
  //# sourceMappingURL=index.js.map
@@ -6,6 +6,13 @@ export interface RecentLine {
6
6
  }
7
7
  /** Classify a raw log line against the error/warning patterns. */
8
8
  export declare function classifyLine(line: string, errorRe: RegExp, warningRe: RegExp): LogLineLevel;
9
+ /**
10
+ * Prepare a log line for display to the whole team: strip terminal escapes,
11
+ * redact anything secret-looking, drop control characters and truncate.
12
+ * Log lines routinely contain tokens and passwords; only redacted text ever
13
+ * leaves the machine.
14
+ */
15
+ export declare function cleanLogLine(line: string, max?: number): string;
9
16
  /**
10
17
  * Push a line into the bounded ring buffer: each entry is capped at `cap`
11
18
  * characters and the buffer keeps only the last `max` entries.
@@ -15,4 +22,19 @@ export declare function pushRecent(buffer: RecentLine[], line: string, level: Lo
15
22
  export declare function buildLogScreen(buffer: RecentLine[]): ScreenData;
16
23
  /** True when enough time has elapsed since the last burst push to emit again. */
17
24
  export declare function shouldEmitBurst(lastEmit: number, now: number, windowMs?: number): boolean;
25
+ /**
26
+ * Sliding-window error/warning counter in one-second buckets: memory is
27
+ * bounded by the window length, not by how many lines a runaway logger writes.
28
+ */
29
+ export declare class WindowCounter {
30
+ private readonly windowMs;
31
+ private buckets;
32
+ constructor(windowMs: number);
33
+ add(level: 'error' | 'warning', now?: number): void;
34
+ counts(now?: number): {
35
+ errors: number;
36
+ warnings: number;
37
+ };
38
+ private prune;
39
+ }
18
40
  //# sourceMappingURL=buffer.d.ts.map