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.
- package/README.md +106 -0
- package/docs/.nojekyll +0 -0
- package/docs/README.md +103 -0
- package/docs/_brand.json +18 -0
- package/docs/_cover.md +13 -0
- package/docs/_sidebar.md +31 -0
- package/docs/_topbar.md +5 -0
- package/docs/_version.json +7 -0
- package/docs/api/README.md +44 -0
- package/docs/api/action-convention.md +148 -0
- package/docs/api/add-action.md +68 -0
- package/docs/api/beacon-capability.md +89 -0
- package/docs/api/build-action-map.md +88 -0
- package/docs/api/connect.md +81 -0
- package/docs/api/disconnect.md +50 -0
- package/docs/api/is-connected.md +33 -0
- package/docs/api/lifecycle-hooks.md +115 -0
- package/docs/architecture.md +237 -0
- package/docs/css/docuserve.css +327 -0
- package/docs/examples/README.md +58 -0
- package/docs/examples/certificate-expiry-monitor.md +212 -0
- package/docs/examples/docker-container-management.md +265 -0
- package/docs/examples/log-archive-and-upload.md +214 -0
- package/docs/examples/log-file-cleanup.md +199 -0
- package/docs/examples/mysql-maintenance.md +253 -0
- package/docs/examples/postgresql-aggregation.md +247 -0
- package/docs/examples/rest-api-health-check.md +213 -0
- package/docs/examples/rest-endpoint-sync.md +240 -0
- package/docs/examples/server-metrics-collection.md +199 -0
- package/docs/examples/shell-commands.md +176 -0
- package/docs/index.html +39 -0
- package/docs/quickstart.md +199 -0
- package/docs/retold-catalog.json +85 -0
- package/docs/retold-keyword-index.json +10642 -0
- package/package.json +33 -0
- package/source/Ultravisor-Beacon-Capability-ActionMap.cjs +132 -0
- package/source/Ultravisor-Beacon-Capability.cjs +276 -0
- 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
|