ultravisor-beacon-capability 1.0.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 (38) hide show
  1. package/README.md +106 -0
  2. package/docs/.nojekyll +0 -0
  3. package/docs/README.md +103 -0
  4. package/docs/_brand.json +18 -0
  5. package/docs/_cover.md +13 -0
  6. package/docs/_sidebar.md +31 -0
  7. package/docs/_topbar.md +5 -0
  8. package/docs/_version.json +7 -0
  9. package/docs/api/README.md +44 -0
  10. package/docs/api/action-convention.md +148 -0
  11. package/docs/api/add-action.md +68 -0
  12. package/docs/api/beacon-capability.md +89 -0
  13. package/docs/api/build-action-map.md +88 -0
  14. package/docs/api/connect.md +81 -0
  15. package/docs/api/disconnect.md +50 -0
  16. package/docs/api/is-connected.md +33 -0
  17. package/docs/api/lifecycle-hooks.md +115 -0
  18. package/docs/architecture.md +237 -0
  19. package/docs/css/docuserve.css +327 -0
  20. package/docs/examples/README.md +58 -0
  21. package/docs/examples/certificate-expiry-monitor.md +212 -0
  22. package/docs/examples/docker-container-management.md +265 -0
  23. package/docs/examples/log-archive-and-upload.md +214 -0
  24. package/docs/examples/log-file-cleanup.md +199 -0
  25. package/docs/examples/mysql-maintenance.md +253 -0
  26. package/docs/examples/postgresql-aggregation.md +247 -0
  27. package/docs/examples/rest-api-health-check.md +213 -0
  28. package/docs/examples/rest-endpoint-sync.md +240 -0
  29. package/docs/examples/server-metrics-collection.md +199 -0
  30. package/docs/examples/shell-commands.md +176 -0
  31. package/docs/index.html +39 -0
  32. package/docs/quickstart.md +199 -0
  33. package/docs/retold-catalog.json +85 -0
  34. package/docs/retold-keyword-index.json +10642 -0
  35. package/package.json +33 -0
  36. package/source/Ultravisor-Beacon-Capability-ActionMap.cjs +132 -0
  37. package/source/Ultravisor-Beacon-Capability.cjs +276 -0
  38. package/test/Ultravisor-Beacon-Capability_tests.js +744 -0
@@ -0,0 +1,253 @@
1
+ # Example: MySQL Maintenance
2
+
3
+ A capability for recurring MySQL database maintenance tasks -- purging old records, optimizing tables, and exporting query results.
4
+
5
+ ## Full Source
6
+
7
+ ```javascript
8
+ const libFable = require('fable');
9
+ const libBeaconCapability = require('ultravisor-beacon-capability');
10
+ const libMySQL = require('mysql2');
11
+
12
+ class MySQLMaintenance extends libBeaconCapability
13
+ {
14
+ constructor(pFable, pOptions, pServiceHash)
15
+ {
16
+ super(pFable, pOptions, pServiceHash);
17
+ this.serviceType = 'MySQLMaintenance';
18
+ this.capabilityName = 'MySQLMaintenance';
19
+
20
+ this._Pool = null;
21
+ }
22
+
23
+ onInitialize(fCallback)
24
+ {
25
+ this._Pool = libMySQL.createPool({
26
+ host: this.fable.settings.MySQL.Host || 'localhost',
27
+ port: this.fable.settings.MySQL.Port || 3306,
28
+ user: this.fable.settings.MySQL.User || 'root',
29
+ password: this.fable.settings.MySQL.Password || '',
30
+ database: this.fable.settings.MySQL.Database || 'myapp',
31
+ connectionLimit: 5
32
+ });
33
+
34
+ // Verify connectivity
35
+ this._Pool.query('SELECT 1 AS alive', (pError) =>
36
+ {
37
+ if (pError)
38
+ {
39
+ this.log.error(`MySQL connection failed: ${pError.message}`);
40
+ return fCallback(pError);
41
+ }
42
+ this.log.info('MySQL connection pool ready');
43
+ return fCallback(null);
44
+ });
45
+ }
46
+
47
+ onShutdown(fCallback)
48
+ {
49
+ if (this._Pool)
50
+ {
51
+ this._Pool.end((pError) =>
52
+ {
53
+ if (pError) this.log.warn(`Pool close error: ${pError.message}`);
54
+ this._Pool = null;
55
+ return fCallback(null);
56
+ });
57
+ }
58
+ else
59
+ {
60
+ return fCallback(null);
61
+ }
62
+ }
63
+
64
+ // --- Action: PurgeOldRecords ---
65
+
66
+ get actionPurgeOldRecords_Description()
67
+ {
68
+ return 'Delete records older than a specified number of days from a table';
69
+ }
70
+
71
+ get actionPurgeOldRecords_Schema()
72
+ {
73
+ return [
74
+ { Name: 'TableName', DataType: 'String', Required: true },
75
+ { Name: 'DateColumn', DataType: 'String', Required: true },
76
+ { Name: 'MaxAgeDays', DataType: 'Integer', Required: true },
77
+ { Name: 'BatchSize', DataType: 'Integer', Required: false, Default: 1000 },
78
+ { Name: 'DryRun', DataType: 'Boolean', Required: false, Default: true }
79
+ ];
80
+ }
81
+
82
+ actionPurgeOldRecords(pSettings, pWorkItem, fCallback, fReportProgress)
83
+ {
84
+ let tmpTable = this._Pool.escapeId(pSettings.TableName);
85
+ let tmpColumn = this._Pool.escapeId(pSettings.DateColumn);
86
+ let tmpDays = parseInt(pSettings.MaxAgeDays, 10);
87
+ let tmpBatch = parseInt(pSettings.BatchSize, 10) || 1000;
88
+
89
+ // Step 1: Count records to delete
90
+ let tmpCountSQL = `SELECT COUNT(*) AS cnt FROM ${tmpTable} WHERE ${tmpColumn} < DATE_SUB(NOW(), INTERVAL ? DAY)`;
91
+
92
+ this._Pool.query(tmpCountSQL, [tmpDays], (pCountError, pCountRows) =>
93
+ {
94
+ if (pCountError) return fCallback(pCountError);
95
+
96
+ let tmpCount = pCountRows[0].cnt;
97
+ fReportProgress({ Percent: 10, Message: `Found ${tmpCount} records to purge` });
98
+
99
+ if (pSettings.DryRun)
100
+ {
101
+ return fCallback(null, {
102
+ Outputs: { RecordsFound: tmpCount, DryRun: true, Deleted: 0 },
103
+ Log: [`DRY RUN: Would delete ${tmpCount} records from ${pSettings.TableName}`]
104
+ });
105
+ }
106
+
107
+ // Step 2: Delete in batches
108
+ let tmpDeleteSQL = `DELETE FROM ${tmpTable} WHERE ${tmpColumn} < DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT ?`;
109
+ let tmpTotalDeleted = 0;
110
+
111
+ let fnDeleteBatch = () =>
112
+ {
113
+ this._Pool.query(tmpDeleteSQL, [tmpDays, tmpBatch], (pDelError, pDelResult) =>
114
+ {
115
+ if (pDelError) return fCallback(pDelError);
116
+
117
+ tmpTotalDeleted += pDelResult.affectedRows;
118
+ let tmpPercent = Math.min(90, Math.round((tmpTotalDeleted / tmpCount) * 80) + 10);
119
+ fReportProgress({ Percent: tmpPercent, Message: `Deleted ${tmpTotalDeleted} / ${tmpCount}` });
120
+
121
+ if (pDelResult.affectedRows < tmpBatch)
122
+ {
123
+ return fCallback(null, {
124
+ Outputs: { RecordsFound: tmpCount, Deleted: tmpTotalDeleted, DryRun: false },
125
+ Log: [`Purged ${tmpTotalDeleted} records from ${pSettings.TableName}`]
126
+ });
127
+ }
128
+
129
+ // Continue batching
130
+ setImmediate(fnDeleteBatch);
131
+ });
132
+ };
133
+
134
+ fnDeleteBatch();
135
+ });
136
+ }
137
+
138
+ // --- Action: OptimizeTable ---
139
+
140
+ get actionOptimizeTable_Description()
141
+ {
142
+ return 'Run OPTIMIZE TABLE to reclaim space and rebuild indexes';
143
+ }
144
+
145
+ get actionOptimizeTable_Schema()
146
+ {
147
+ return [
148
+ { Name: 'TableName', DataType: 'String', Required: true }
149
+ ];
150
+ }
151
+
152
+ actionOptimizeTable(pSettings, pWorkItem, fCallback)
153
+ {
154
+ let tmpTable = this._Pool.escapeId(pSettings.TableName);
155
+ let tmpSQL = `OPTIMIZE TABLE ${tmpTable}`;
156
+
157
+ this.log.info(`Optimizing table ${pSettings.TableName}...`);
158
+
159
+ this._Pool.query(tmpSQL, (pError, pResults) =>
160
+ {
161
+ if (pError) return fCallback(pError);
162
+ return fCallback(null, {
163
+ Outputs: { Result: pResults },
164
+ Log: [`Optimized table ${pSettings.TableName}`]
165
+ });
166
+ });
167
+ }
168
+
169
+ // --- Action: ExportQuery ---
170
+
171
+ get actionExportQuery_Description()
172
+ {
173
+ return 'Execute a SELECT query and return the results as JSON';
174
+ }
175
+
176
+ get actionExportQuery_Schema()
177
+ {
178
+ return [
179
+ { Name: 'Query', DataType: 'String', Required: true },
180
+ { Name: 'MaxRows', DataType: 'Integer', Required: false, Default: 10000 }
181
+ ];
182
+ }
183
+
184
+ actionExportQuery(pSettings, pWorkItem, fCallback)
185
+ {
186
+ let tmpQuery = pSettings.Query;
187
+
188
+ // Safety: reject non-SELECT queries
189
+ if (!tmpQuery.trim().toUpperCase().startsWith('SELECT'))
190
+ {
191
+ return fCallback(new Error('ExportQuery only supports SELECT statements'));
192
+ }
193
+
194
+ let tmpMaxRows = parseInt(pSettings.MaxRows, 10) || 10000;
195
+ let tmpSQL = `${tmpQuery} LIMIT ${tmpMaxRows}`;
196
+
197
+ this._Pool.query(tmpSQL, (pError, pRows) =>
198
+ {
199
+ if (pError) return fCallback(pError);
200
+ return fCallback(null, {
201
+ Outputs: { Rows: pRows, RowCount: pRows.length, Truncated: pRows.length >= tmpMaxRows },
202
+ Log: [`Exported ${pRows.length} rows`]
203
+ });
204
+ });
205
+ }
206
+ }
207
+
208
+ // --- Startup ---
209
+
210
+ let tmpFable = new libFable({
211
+ Product: 'MySQLMaintenance',
212
+ ProductVersion: '1.0.0',
213
+ MySQL:
214
+ {
215
+ Host: process.env.MYSQL_HOST || 'localhost',
216
+ Port: parseInt(process.env.MYSQL_PORT, 10) || 3306,
217
+ User: process.env.MYSQL_USER || 'root',
218
+ Password: process.env.MYSQL_PASSWORD || '',
219
+ Database: process.env.MYSQL_DATABASE || 'myapp'
220
+ }
221
+ });
222
+
223
+ tmpFable.addServiceType('MySQLMaintenance', MySQLMaintenance);
224
+ let tmpCap = tmpFable.instantiateServiceProvider('MySQLMaintenance');
225
+
226
+ tmpCap.connect(
227
+ {
228
+ ServerURL: process.env.ULTRAVISOR_URL || 'http://localhost:54321',
229
+ Name: 'mysql-maintenance'
230
+ },
231
+ (pError) =>
232
+ {
233
+ if (pError) throw pError;
234
+ console.log('MySQL maintenance beacon online');
235
+ });
236
+
237
+ process.on('SIGTERM', () => { tmpCap.disconnect(() => process.exit(0)); });
238
+ ```
239
+
240
+ ## Registered Task Types
241
+
242
+ - `beacon-mysqlmaintenance-purgeoldrecords`
243
+ - `beacon-mysqlmaintenance-optimizetable`
244
+ - `beacon-mysqlmaintenance-exportquery`
245
+
246
+ ## Key Points
247
+
248
+ - **Connection pool** is created in `onInitialize` and closed in `onShutdown`
249
+ - **Batch deletion** uses `LIMIT` and `setImmediate` to avoid locking the table for too long
250
+ - **Progress reporting** gives visibility into long-running purge operations
251
+ - **DryRun mode** counts affected records without deleting (defaults to `true` for safety)
252
+ - **ExportQuery** rejects non-SELECT statements to prevent accidental mutations
253
+ - Database credentials come from Fable settings, which can be sourced from environment variables
@@ -0,0 +1,247 @@
1
+ # Example: PostgreSQL Aggregation
2
+
3
+ A capability for running aggregation queries, refreshing materialized views, and collecting table statistics on a PostgreSQL database.
4
+
5
+ ## Full Source
6
+
7
+ ```javascript
8
+ const libFable = require('fable');
9
+ const libBeaconCapability = require('ultravisor-beacon-capability');
10
+ const { Client } = require('pg');
11
+
12
+ class PostgresAggregation extends libBeaconCapability
13
+ {
14
+ constructor(pFable, pOptions, pServiceHash)
15
+ {
16
+ super(pFable, pOptions, pServiceHash);
17
+ this.serviceType = 'PostgresAggregation';
18
+ this.capabilityName = 'PostgresAggregation';
19
+
20
+ this._Client = null;
21
+ }
22
+
23
+ onInitialize(fCallback)
24
+ {
25
+ this._Client = new Client({
26
+ host: this.fable.settings.Postgres.Host || 'localhost',
27
+ port: this.fable.settings.Postgres.Port || 5432,
28
+ user: this.fable.settings.Postgres.User || 'postgres',
29
+ password: this.fable.settings.Postgres.Password || '',
30
+ database: this.fable.settings.Postgres.Database || 'analytics'
31
+ });
32
+
33
+ this._Client.connect((pError) =>
34
+ {
35
+ if (pError)
36
+ {
37
+ this.log.error(`PostgreSQL connection failed: ${pError.message}`);
38
+ return fCallback(pError);
39
+ }
40
+ this.log.info('PostgreSQL client connected');
41
+ return fCallback(null);
42
+ });
43
+ }
44
+
45
+ onShutdown(fCallback)
46
+ {
47
+ if (this._Client)
48
+ {
49
+ this._Client.end()
50
+ .then(() =>
51
+ {
52
+ this._Client = null;
53
+ return fCallback(null);
54
+ })
55
+ .catch((pError) =>
56
+ {
57
+ this.log.warn(`PG disconnect error: ${pError.message}`);
58
+ this._Client = null;
59
+ return fCallback(null);
60
+ });
61
+ }
62
+ else
63
+ {
64
+ return fCallback(null);
65
+ }
66
+ }
67
+
68
+ // --- Action: RefreshMaterializedView ---
69
+
70
+ get actionRefreshMaterializedView_Description()
71
+ {
72
+ return 'Refresh a materialized view, optionally concurrently';
73
+ }
74
+
75
+ get actionRefreshMaterializedView_Schema()
76
+ {
77
+ return [
78
+ { Name: 'ViewName', DataType: 'String', Required: true },
79
+ { Name: 'Concurrently', DataType: 'Boolean', Required: false, Default: true }
80
+ ];
81
+ }
82
+
83
+ actionRefreshMaterializedView(pSettings, pWorkItem, fCallback)
84
+ {
85
+ let tmpConcurrently = (pSettings.Concurrently !== false) ? 'CONCURRENTLY' : '';
86
+ let tmpSQL = `REFRESH MATERIALIZED VIEW ${tmpConcurrently} ${pSettings.ViewName}`;
87
+
88
+ this.log.info(`Refreshing view: ${pSettings.ViewName}`);
89
+ let tmpStart = Date.now();
90
+
91
+ this._Client.query(tmpSQL, (pError) =>
92
+ {
93
+ if (pError) return fCallback(pError);
94
+ let tmpDuration = Date.now() - tmpStart;
95
+ return fCallback(null, {
96
+ Outputs: { ViewName: pSettings.ViewName, DurationMs: tmpDuration },
97
+ Log: [`Refreshed ${pSettings.ViewName} in ${tmpDuration}ms`]
98
+ });
99
+ });
100
+ }
101
+
102
+ // --- Action: RunAggregation ---
103
+
104
+ get actionRunAggregation_Description()
105
+ {
106
+ return 'Execute an aggregation query and return the results';
107
+ }
108
+
109
+ get actionRunAggregation_Schema()
110
+ {
111
+ return [
112
+ { Name: 'Query', DataType: 'String', Required: true },
113
+ { Name: 'Parameters', DataType: 'Array', Required: false }
114
+ ];
115
+ }
116
+
117
+ actionRunAggregation(pSettings, pWorkItem, fCallback)
118
+ {
119
+ let tmpQuery = pSettings.Query;
120
+
121
+ // Safety: only allow read-only queries
122
+ let tmpUpper = tmpQuery.trim().toUpperCase();
123
+ if (!tmpUpper.startsWith('SELECT') && !tmpUpper.startsWith('WITH'))
124
+ {
125
+ return fCallback(new Error('RunAggregation only supports SELECT and CTE queries'));
126
+ }
127
+
128
+ let tmpParams = pSettings.Parameters || [];
129
+ let tmpStart = Date.now();
130
+
131
+ this._Client.query(tmpQuery, tmpParams, (pError, pResult) =>
132
+ {
133
+ if (pError) return fCallback(pError);
134
+ let tmpDuration = Date.now() - tmpStart;
135
+ return fCallback(null, {
136
+ Outputs: {
137
+ Rows: pResult.rows,
138
+ RowCount: pResult.rowCount,
139
+ DurationMs: tmpDuration,
140
+ Fields: pResult.fields.map((pField) => pField.name)
141
+ },
142
+ Log: [`Aggregation returned ${pResult.rowCount} rows in ${tmpDuration}ms`]
143
+ });
144
+ });
145
+ }
146
+
147
+ // --- Action: TableStatistics ---
148
+
149
+ get actionTableStatistics_Description()
150
+ {
151
+ return 'Collect size, row count, and index statistics for specified tables';
152
+ }
153
+
154
+ get actionTableStatistics_Schema()
155
+ {
156
+ return [
157
+ { Name: 'SchemaName', DataType: 'String', Required: false, Default: 'public' },
158
+ { Name: 'Tables', DataType: 'Array', Required: false }
159
+ ];
160
+ }
161
+
162
+ actionTableStatistics(pSettings, pWorkItem, fCallback)
163
+ {
164
+ let tmpSchema = pSettings.SchemaName || 'public';
165
+
166
+ let tmpSQL = `
167
+ SELECT
168
+ relname AS table_name,
169
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
170
+ pg_size_pretty(pg_relation_size(c.oid)) AS data_size,
171
+ pg_size_pretty(pg_indexes_size(c.oid)) AS index_size,
172
+ n_live_tup AS estimated_rows,
173
+ last_vacuum,
174
+ last_autovacuum,
175
+ last_analyze
176
+ FROM pg_class c
177
+ JOIN pg_stat_user_tables s ON c.relname = s.relname
178
+ WHERE c.relkind = 'r'
179
+ AND s.schemaname = $1
180
+ ORDER BY pg_total_relation_size(c.oid) DESC
181
+ `;
182
+
183
+ this._Client.query(tmpSQL, [tmpSchema], (pError, pResult) =>
184
+ {
185
+ if (pError) return fCallback(pError);
186
+
187
+ let tmpRows = pResult.rows;
188
+
189
+ // Filter to specific tables if requested
190
+ if (pSettings.Tables && pSettings.Tables.length > 0)
191
+ {
192
+ let tmpTableSet = new Set(pSettings.Tables);
193
+ tmpRows = tmpRows.filter((pRow) => tmpTableSet.has(pRow.table_name));
194
+ }
195
+
196
+ return fCallback(null, {
197
+ Outputs: { Tables: tmpRows, Schema: tmpSchema },
198
+ Log: [`Collected statistics for ${tmpRows.length} tables in ${tmpSchema}`]
199
+ });
200
+ });
201
+ }
202
+ }
203
+
204
+ // --- Startup ---
205
+
206
+ let tmpFable = new libFable({
207
+ Product: 'PostgresAggregation',
208
+ ProductVersion: '1.0.0',
209
+ Postgres:
210
+ {
211
+ Host: process.env.PGHOST || 'localhost',
212
+ Port: parseInt(process.env.PGPORT, 10) || 5432,
213
+ User: process.env.PGUSER || 'postgres',
214
+ Password: process.env.PGPASSWORD || '',
215
+ Database: process.env.PGDATABASE || 'analytics'
216
+ }
217
+ });
218
+
219
+ tmpFable.addServiceType('PostgresAggregation', PostgresAggregation);
220
+ let tmpCap = tmpFable.instantiateServiceProvider('PostgresAggregation');
221
+
222
+ tmpCap.connect(
223
+ {
224
+ ServerURL: process.env.ULTRAVISOR_URL || 'http://localhost:54321',
225
+ Name: 'postgres-aggregation'
226
+ },
227
+ (pError) =>
228
+ {
229
+ if (pError) throw pError;
230
+ console.log('PostgreSQL aggregation beacon online');
231
+ });
232
+
233
+ process.on('SIGTERM', () => { tmpCap.disconnect(() => process.exit(0)); });
234
+ ```
235
+
236
+ ## Registered Task Types
237
+
238
+ - `beacon-postgresaggregation-refreshmaterializedview`
239
+ - `beacon-postgresaggregation-runaggregation`
240
+ - `beacon-postgresaggregation-tablestatistics`
241
+
242
+ ## Key Points
243
+
244
+ - **Materialized view refresh** supports `CONCURRENTLY` for zero-downtime refreshes
245
+ - **RunAggregation** accepts parameterized queries via `Parameters` array to prevent SQL injection
246
+ - **TableStatistics** uses PostgreSQL system catalogs for accurate size and vacuum information
247
+ - Query duration is measured and included in outputs for performance monitoring
@@ -0,0 +1,213 @@
1
+ # Example: REST API Health Check
2
+
3
+ A capability that monitors multiple REST endpoints and reports their health status. Useful for synthetic monitoring when you want results captured in Ultravisor rather than a separate monitoring tool.
4
+
5
+ ## Full Source
6
+
7
+ ```javascript
8
+ const libFable = require('fable');
9
+ const libBeaconCapability = require('ultravisor-beacon-capability');
10
+ const libHTTPS = require('https');
11
+ const libHTTP = require('http');
12
+ const libURL = require('url');
13
+
14
+ class RESTHealthCheck extends libBeaconCapability
15
+ {
16
+ constructor(pFable, pOptions, pServiceHash)
17
+ {
18
+ super(pFable, pOptions, pServiceHash);
19
+ this.serviceType = 'RESTHealthCheck';
20
+ this.capabilityName = 'RESTHealthCheck';
21
+ }
22
+
23
+ // --- Action: CheckEndpoint ---
24
+
25
+ get actionCheckEndpoint_Description()
26
+ {
27
+ return 'Check a single REST endpoint and report status, latency, and response';
28
+ }
29
+
30
+ get actionCheckEndpoint_Schema()
31
+ {
32
+ return [
33
+ { Name: 'URL', DataType: 'String', Required: true },
34
+ { Name: 'Method', DataType: 'String', Required: false, Default: 'GET' },
35
+ { Name: 'ExpectedStatus', DataType: 'Integer', Required: false, Default: 200 },
36
+ { Name: 'TimeoutMs', DataType: 'Integer', Required: false, Default: 10000 },
37
+ { Name: 'Headers', DataType: 'Object', Required: false }
38
+ ];
39
+ }
40
+
41
+ actionCheckEndpoint(pSettings, pWorkItem, fCallback)
42
+ {
43
+ let tmpParsed = libURL.parse(pSettings.URL);
44
+ let tmpLib = (tmpParsed.protocol === 'https:') ? libHTTPS : libHTTP;
45
+ let tmpStart = Date.now();
46
+
47
+ let tmpOptions = {
48
+ hostname: tmpParsed.hostname,
49
+ port: tmpParsed.port,
50
+ path: tmpParsed.path,
51
+ method: pSettings.Method || 'GET',
52
+ timeout: pSettings.TimeoutMs || 10000,
53
+ headers: pSettings.Headers || {}
54
+ };
55
+
56
+ let tmpReq = tmpLib.request(tmpOptions, (pRes) =>
57
+ {
58
+ let tmpBody = '';
59
+ pRes.on('data', (pChunk) => { tmpBody += pChunk; });
60
+ pRes.on('end', () =>
61
+ {
62
+ let tmpLatency = Date.now() - tmpStart;
63
+ let tmpExpected = pSettings.ExpectedStatus || 200;
64
+ let tmpHealthy = (pRes.statusCode === tmpExpected);
65
+
66
+ return fCallback(null, {
67
+ Outputs: {
68
+ URL: pSettings.URL,
69
+ StatusCode: pRes.statusCode,
70
+ ExpectedStatus: tmpExpected,
71
+ Healthy: tmpHealthy,
72
+ LatencyMs: tmpLatency,
73
+ ResponseBody: tmpBody.substring(0, 2048),
74
+ Headers: pRes.headers
75
+ },
76
+ Log: [
77
+ `${pSettings.URL} -> ${pRes.statusCode} (${tmpLatency}ms) ${tmpHealthy ? 'HEALTHY' : 'UNHEALTHY'}`
78
+ ]
79
+ });
80
+ });
81
+ });
82
+
83
+ tmpReq.on('timeout', () =>
84
+ {
85
+ tmpReq.destroy();
86
+ return fCallback(null, {
87
+ Outputs: {
88
+ URL: pSettings.URL,
89
+ Healthy: false,
90
+ LatencyMs: Date.now() - tmpStart,
91
+ Error: 'Request timed out'
92
+ },
93
+ Log: [`${pSettings.URL} -> TIMEOUT`]
94
+ });
95
+ });
96
+
97
+ tmpReq.on('error', (pError) =>
98
+ {
99
+ return fCallback(null, {
100
+ Outputs: {
101
+ URL: pSettings.URL,
102
+ Healthy: false,
103
+ LatencyMs: Date.now() - tmpStart,
104
+ Error: pError.message
105
+ },
106
+ Log: [`${pSettings.URL} -> ERROR: ${pError.message}`]
107
+ });
108
+ });
109
+
110
+ tmpReq.end();
111
+ }
112
+
113
+ // --- Action: CheckMultiple ---
114
+
115
+ get actionCheckMultiple_Description()
116
+ {
117
+ return 'Check multiple endpoints in parallel and return a consolidated health report';
118
+ }
119
+
120
+ get actionCheckMultiple_Schema()
121
+ {
122
+ return [
123
+ { Name: 'Endpoints', DataType: 'Array', Required: true, Description: 'Array of { URL, Method, ExpectedStatus }' },
124
+ { Name: 'TimeoutMs', DataType: 'Integer', Required: false, Default: 10000 }
125
+ ];
126
+ }
127
+
128
+ actionCheckMultiple(pSettings, pWorkItem, fCallback, fReportProgress)
129
+ {
130
+ let tmpEndpoints = pSettings.Endpoints || [];
131
+ let tmpResults = [];
132
+ let tmpCompleted = 0;
133
+ let tmpTotal = tmpEndpoints.length;
134
+
135
+ if (tmpTotal === 0)
136
+ {
137
+ return fCallback(null, {
138
+ Outputs: { Results: [], AllHealthy: true, HealthyCount: 0, TotalCount: 0 },
139
+ Log: ['No endpoints to check']
140
+ });
141
+ }
142
+
143
+ tmpEndpoints.forEach((pEndpoint, pIndex) =>
144
+ {
145
+ let tmpCheckSettings = {
146
+ URL: pEndpoint.URL || pEndpoint,
147
+ Method: pEndpoint.Method || 'GET',
148
+ ExpectedStatus: pEndpoint.ExpectedStatus || 200,
149
+ TimeoutMs: pSettings.TimeoutMs || 10000,
150
+ Headers: pEndpoint.Headers || {}
151
+ };
152
+
153
+ this.actionCheckEndpoint(tmpCheckSettings, pWorkItem, (pError, pResult) =>
154
+ {
155
+ tmpCompleted++;
156
+ tmpResults.push(pResult ? pResult.Outputs : { URL: tmpCheckSettings.URL, Healthy: false, Error: 'Check failed' });
157
+
158
+ fReportProgress({
159
+ Percent: Math.round((tmpCompleted / tmpTotal) * 100),
160
+ Message: `Checked ${tmpCompleted} / ${tmpTotal} endpoints`
161
+ });
162
+
163
+ if (tmpCompleted === tmpTotal)
164
+ {
165
+ let tmpHealthyCount = tmpResults.filter((pR) => pR.Healthy).length;
166
+ return fCallback(null, {
167
+ Outputs: {
168
+ Results: tmpResults,
169
+ AllHealthy: tmpHealthyCount === tmpTotal,
170
+ HealthyCount: tmpHealthyCount,
171
+ UnhealthyCount: tmpTotal - tmpHealthyCount,
172
+ TotalCount: tmpTotal
173
+ },
174
+ Log: [`Health check complete: ${tmpHealthyCount}/${tmpTotal} healthy`]
175
+ });
176
+ }
177
+ });
178
+ });
179
+ }
180
+ }
181
+
182
+ // --- Startup ---
183
+
184
+ let tmpFable = new libFable({ Product: 'RESTHealthCheck', ProductVersion: '1.0.0' });
185
+ tmpFable.addServiceType('RESTHealthCheck', RESTHealthCheck);
186
+ let tmpCap = tmpFable.instantiateServiceProvider('RESTHealthCheck');
187
+
188
+ tmpCap.connect(
189
+ {
190
+ ServerURL: process.env.ULTRAVISOR_URL || 'http://localhost:54321',
191
+ Name: 'rest-health-checker'
192
+ },
193
+ (pError) =>
194
+ {
195
+ if (pError) throw pError;
196
+ console.log('REST health check beacon online');
197
+ });
198
+
199
+ process.on('SIGTERM', () => { tmpCap.disconnect(() => process.exit(0)); });
200
+ ```
201
+
202
+ ## Registered Task Types
203
+
204
+ - `beacon-resthealthcheck-checkendpoint`
205
+ - `beacon-resthealthcheck-checkmultiple`
206
+
207
+ ## Key Points
208
+
209
+ - **No external HTTP library** -- uses Node.js built-in `http`/`https` modules
210
+ - **Timeout handling** returns results (not errors) so the work item always completes
211
+ - **CheckMultiple** runs all checks in parallel for speed, with progress reporting
212
+ - **Response body** is truncated to 2KB to avoid excessive output
213
+ - Schedule `CheckMultiple` on a cron in Ultravisor to get continuous monitoring with history