@xiboplayer/stats 0.1.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 +375 -0
- package/package.json +37 -0
- package/src/index.js +13 -0
- package/src/log-reporter.js +541 -0
- package/src/log-reporter.test.js +484 -0
- package/src/stats-collector.js +633 -0
- package/src/stats-collector.test.js +461 -0
- package/vitest.config.js +9 -0
- package/vitest.setup.js +2 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogReporter - CMS logging for Xibo Players
|
|
3
|
+
*
|
|
4
|
+
* Collects and submits logs to CMS via XMDS.
|
|
5
|
+
* Uses IndexedDB for persistent storage across sessions.
|
|
6
|
+
*
|
|
7
|
+
* @module @xiboplayer/stats/logger
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
11
|
+
|
|
12
|
+
const log = createLogger('@xiboplayer/stats');
|
|
13
|
+
|
|
14
|
+
// IndexedDB configuration
|
|
15
|
+
const DB_NAME = 'xibo-player-logs';
|
|
16
|
+
const DB_VERSION = 1;
|
|
17
|
+
const LOGS_STORE = 'logs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Log reporter for CMS logging
|
|
21
|
+
*
|
|
22
|
+
* Stores log entries in IndexedDB and submits to CMS via XMDS.
|
|
23
|
+
* Supports multiple log levels: error, audit, info, debug.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const reporter = new LogReporter();
|
|
27
|
+
* await reporter.init();
|
|
28
|
+
*
|
|
29
|
+
* // Log messages
|
|
30
|
+
* await reporter.error('Failed to load layout', 'PLAYER');
|
|
31
|
+
* await reporter.info('Layout loaded successfully', 'PLAYER');
|
|
32
|
+
*
|
|
33
|
+
* // Get logs for submission
|
|
34
|
+
* const logs = await reporter.getLogsForSubmission(100);
|
|
35
|
+
* const xml = formatLogs(logs);
|
|
36
|
+
* // ... submit to CMS ...
|
|
37
|
+
* await reporter.clearSubmittedLogs(logs);
|
|
38
|
+
*/
|
|
39
|
+
export class LogReporter {
|
|
40
|
+
constructor() {
|
|
41
|
+
this.db = null;
|
|
42
|
+
this._reportedFaults = new Map(); // code -> timestamp (deduplication)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Initialize IndexedDB
|
|
47
|
+
*
|
|
48
|
+
* Creates logs store with index on 'submitted' field for fast queries.
|
|
49
|
+
* Safe to call multiple times (idempotent).
|
|
50
|
+
*
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
* @throws {Error} If IndexedDB is not available or initialization fails
|
|
53
|
+
*/
|
|
54
|
+
async init() {
|
|
55
|
+
if (this.db) {
|
|
56
|
+
log.debug('Log reporter already initialized');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
// Check if IndexedDB is available
|
|
62
|
+
if (typeof indexedDB === 'undefined') {
|
|
63
|
+
const error = new Error('IndexedDB not available');
|
|
64
|
+
log.error('IndexedDB not available - logs will not be persisted');
|
|
65
|
+
reject(error);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
70
|
+
|
|
71
|
+
request.onerror = () => {
|
|
72
|
+
const error = new Error(`Failed to open IndexedDB: ${request.error}`);
|
|
73
|
+
log.error('Failed to open logs database:', request.error);
|
|
74
|
+
reject(error);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
request.onsuccess = () => {
|
|
78
|
+
this.db = request.result;
|
|
79
|
+
log.info('Logs database initialized');
|
|
80
|
+
resolve();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
request.onupgradeneeded = (event) => {
|
|
84
|
+
const db = event.target.result;
|
|
85
|
+
|
|
86
|
+
// Create logs store if it doesn't exist
|
|
87
|
+
if (!db.objectStoreNames.contains(LOGS_STORE)) {
|
|
88
|
+
const store = db.createObjectStore(LOGS_STORE, {
|
|
89
|
+
keyPath: 'id',
|
|
90
|
+
autoIncrement: true
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Index on 'submitted' for fast queries
|
|
94
|
+
store.createIndex('submitted', 'submitted', { unique: false });
|
|
95
|
+
|
|
96
|
+
log.info('Logs store created');
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Log a message
|
|
104
|
+
*
|
|
105
|
+
* Stores a log entry in IndexedDB for later submission to CMS.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} level - Log level: 'error', 'audit', 'info', or 'debug'
|
|
108
|
+
* @param {string} message - Log message
|
|
109
|
+
* @param {string} category - Log category (default: 'PLAYER')
|
|
110
|
+
* @param {Object} [extra] - Optional extra fields (alertType, eventType)
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*/
|
|
113
|
+
async log(level, message, category = 'PLAYER', extra = null) {
|
|
114
|
+
if (!this.db) {
|
|
115
|
+
// Use console directly — NOT the logger — to avoid infinite feedback loop.
|
|
116
|
+
// The logger dispatches to log sinks, and this method IS the sink target.
|
|
117
|
+
console.warn('[LogReporter] Database not initialized, dropping log entry');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Validate log level
|
|
122
|
+
const validLevels = ['error', 'warning', 'audit', 'info', 'debug'];
|
|
123
|
+
if (!validLevels.includes(level)) {
|
|
124
|
+
level = 'info';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const logEntry = {
|
|
128
|
+
level,
|
|
129
|
+
message,
|
|
130
|
+
category,
|
|
131
|
+
timestamp: new Date(),
|
|
132
|
+
submitted: 0 // Use 0/1 instead of boolean for IndexedDB compatibility
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Add alert fields for faults (triggers CMS dashboard alerts)
|
|
136
|
+
if (extra) {
|
|
137
|
+
if (extra.alertType) logEntry.alertType = extra.alertType;
|
|
138
|
+
if (extra.eventType) logEntry.eventType = extra.eventType;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await this._saveLog(logEntry);
|
|
143
|
+
// NOTE: Do NOT call log.debug() here — it dispatches to sinks, which call
|
|
144
|
+
// logReporter.log() again, creating an infinite async loop.
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// Use console directly to avoid feedback loop
|
|
147
|
+
console.error('[LogReporter] Failed to save log entry:', error);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Report a fault to CMS (special log entry that triggers alerts)
|
|
154
|
+
*
|
|
155
|
+
* Faults are log entries with alertType/eventType fields that cause the
|
|
156
|
+
* CMS to show alerts on the display dashboard and optionally send emails.
|
|
157
|
+
* Deduplicates by code: same fault code won't be reported again within
|
|
158
|
+
* the cooldown period (default 5 minutes).
|
|
159
|
+
*
|
|
160
|
+
* @param {string} code - Fault code (e.g., 'LAYOUT_LOAD_FAILED')
|
|
161
|
+
* @param {string} reason - Human-readable description
|
|
162
|
+
* @param {number} [cooldownMs=300000] - Dedup cooldown in ms (default 5 min)
|
|
163
|
+
* @returns {Promise<void>}
|
|
164
|
+
*/
|
|
165
|
+
async reportFault(code, reason, cooldownMs = 300000) {
|
|
166
|
+
// Deduplication: skip if same code was reported recently
|
|
167
|
+
const lastReported = this._reportedFaults.get(code);
|
|
168
|
+
if (lastReported && (Date.now() - lastReported) < cooldownMs) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this._reportedFaults.set(code, Date.now());
|
|
173
|
+
|
|
174
|
+
await this.log('error', reason, 'PLAYER', {
|
|
175
|
+
alertType: 'Player Fault',
|
|
176
|
+
eventType: code
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
log.info(`Fault reported: ${code} - ${reason}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Log an error message
|
|
184
|
+
*
|
|
185
|
+
* Shorthand for log('error', message, category)
|
|
186
|
+
*
|
|
187
|
+
* @param {string} message - Error message
|
|
188
|
+
* @param {string} category - Log category (default: 'PLAYER')
|
|
189
|
+
* @returns {Promise<void>}
|
|
190
|
+
*/
|
|
191
|
+
async error(message, category = 'PLAYER') {
|
|
192
|
+
return this.log('error', message, category);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Log an audit message
|
|
197
|
+
*
|
|
198
|
+
* Shorthand for log('audit', message, category)
|
|
199
|
+
*
|
|
200
|
+
* @param {string} message - Audit message
|
|
201
|
+
* @param {string} category - Log category (default: 'PLAYER')
|
|
202
|
+
* @returns {Promise<void>}
|
|
203
|
+
*/
|
|
204
|
+
async audit(message, category = 'PLAYER') {
|
|
205
|
+
return this.log('audit', message, category);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Log an info message
|
|
210
|
+
*
|
|
211
|
+
* Shorthand for log('info', message, category)
|
|
212
|
+
*
|
|
213
|
+
* @param {string} message - Info message
|
|
214
|
+
* @param {string} category - Log category (default: 'PLAYER')
|
|
215
|
+
* @returns {Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
async info(message, category = 'PLAYER') {
|
|
218
|
+
return this.log('info', message, category);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Log a debug message
|
|
223
|
+
*
|
|
224
|
+
* Shorthand for log('debug', message, category)
|
|
225
|
+
*
|
|
226
|
+
* @param {string} message - Debug message
|
|
227
|
+
* @param {string} category - Log category (default: 'PLAYER')
|
|
228
|
+
* @returns {Promise<void>}
|
|
229
|
+
*/
|
|
230
|
+
async debug(message, category = 'PLAYER') {
|
|
231
|
+
return this.log('debug', message, category);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get logs ready for submission to CMS
|
|
236
|
+
*
|
|
237
|
+
* Returns unsubmitted logs up to the specified limit.
|
|
238
|
+
* Logs are ordered by ID (oldest first).
|
|
239
|
+
*
|
|
240
|
+
* @param {number} limit - Maximum number of logs to return (default: 100)
|
|
241
|
+
* @returns {Promise<Array>} Array of log objects
|
|
242
|
+
*/
|
|
243
|
+
async getLogsForSubmission(limit = 100) {
|
|
244
|
+
if (!this.db) {
|
|
245
|
+
log.warn('Logs database not initialized');
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readonly');
|
|
251
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
252
|
+
const index = store.index('submitted');
|
|
253
|
+
|
|
254
|
+
// Query for unsubmitted logs (0 = false)
|
|
255
|
+
const request = index.openCursor(IDBKeyRange.only(0));
|
|
256
|
+
const logs = [];
|
|
257
|
+
|
|
258
|
+
request.onsuccess = (event) => {
|
|
259
|
+
const cursor = event.target.result;
|
|
260
|
+
|
|
261
|
+
if (cursor && logs.length < limit) {
|
|
262
|
+
logs.push(cursor.value);
|
|
263
|
+
cursor.continue();
|
|
264
|
+
} else {
|
|
265
|
+
log.debug(`Retrieved ${logs.length} unsubmitted logs`);
|
|
266
|
+
resolve(logs);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
request.onerror = () => {
|
|
271
|
+
log.error('Failed to retrieve logs:', request.error);
|
|
272
|
+
reject(new Error(`Failed to retrieve logs: ${request.error}`));
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Clear submitted logs from database
|
|
279
|
+
*
|
|
280
|
+
* Deletes logs that were successfully submitted to CMS.
|
|
281
|
+
*
|
|
282
|
+
* @param {Array} logs - Array of log objects to delete
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
async clearSubmittedLogs(logs) {
|
|
286
|
+
if (!this.db) {
|
|
287
|
+
log.warn('Logs database not initialized');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!logs || logs.length === 0) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
|
|
297
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
298
|
+
|
|
299
|
+
let deletedCount = 0;
|
|
300
|
+
|
|
301
|
+
logs.forEach((logEntry) => {
|
|
302
|
+
if (logEntry.id) {
|
|
303
|
+
const request = store.delete(logEntry.id);
|
|
304
|
+
request.onsuccess = () => {
|
|
305
|
+
deletedCount++;
|
|
306
|
+
};
|
|
307
|
+
request.onerror = () => {
|
|
308
|
+
log.error(`Failed to delete log ${logEntry.id}:`, request.error);
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
transaction.oncomplete = () => {
|
|
314
|
+
log.debug(`Deleted ${deletedCount} submitted logs`);
|
|
315
|
+
resolve();
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
transaction.onerror = () => {
|
|
319
|
+
log.error('Failed to delete submitted logs:', transaction.error);
|
|
320
|
+
reject(new Error(`Failed to delete logs: ${transaction.error}`));
|
|
321
|
+
};
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get all logs (for debugging)
|
|
327
|
+
*
|
|
328
|
+
* @returns {Promise<Array>} All logs in database
|
|
329
|
+
*/
|
|
330
|
+
async getAllLogs() {
|
|
331
|
+
if (!this.db) {
|
|
332
|
+
log.warn('Logs database not initialized');
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readonly');
|
|
338
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
339
|
+
const request = store.getAll();
|
|
340
|
+
|
|
341
|
+
request.onsuccess = () => {
|
|
342
|
+
resolve(request.result);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
request.onerror = () => {
|
|
346
|
+
log.error('Failed to get all logs:', request.error);
|
|
347
|
+
reject(new Error(`Failed to get all logs: ${request.error}`));
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Clear all logs (for testing)
|
|
354
|
+
*
|
|
355
|
+
* @returns {Promise<void>}
|
|
356
|
+
*/
|
|
357
|
+
async clearAllLogs() {
|
|
358
|
+
if (!this.db) {
|
|
359
|
+
log.warn('Logs database not initialized');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
|
|
365
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
366
|
+
const request = store.clear();
|
|
367
|
+
|
|
368
|
+
request.onsuccess = () => {
|
|
369
|
+
log.debug('Cleared all logs');
|
|
370
|
+
resolve();
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
request.onerror = () => {
|
|
374
|
+
log.error('Failed to clear all logs:', request.error);
|
|
375
|
+
reject(new Error(`Failed to clear logs: ${request.error}`));
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Save a log entry to IndexedDB
|
|
382
|
+
* @private
|
|
383
|
+
*/
|
|
384
|
+
async _saveLog(logEntry) {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
|
|
387
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
388
|
+
const request = store.add(logEntry);
|
|
389
|
+
|
|
390
|
+
request.onsuccess = () => {
|
|
391
|
+
resolve(request.result);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
request.onerror = () => {
|
|
395
|
+
// Check for quota exceeded error
|
|
396
|
+
if (request.error.name === 'QuotaExceededError') {
|
|
397
|
+
console.warn('[LogReporter] IndexedDB quota exceeded - cleaning old logs');
|
|
398
|
+
this._cleanOldLogs().then(() => {
|
|
399
|
+
// Retry once after cleanup
|
|
400
|
+
const retryRequest = store.add(logEntry);
|
|
401
|
+
retryRequest.onsuccess = () => resolve(retryRequest.result);
|
|
402
|
+
retryRequest.onerror = () => reject(retryRequest.error);
|
|
403
|
+
}).catch(reject);
|
|
404
|
+
} else {
|
|
405
|
+
reject(request.error);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Clean old logs when quota is exceeded
|
|
413
|
+
* Deletes oldest 100 submitted logs
|
|
414
|
+
* @private
|
|
415
|
+
*/
|
|
416
|
+
async _cleanOldLogs() {
|
|
417
|
+
if (!this.db) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return new Promise((resolve, reject) => {
|
|
422
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
|
|
423
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
424
|
+
const index = store.index('submitted');
|
|
425
|
+
|
|
426
|
+
// Get oldest 100 submitted logs (use 1 for boolean true in IndexedDB)
|
|
427
|
+
const request = index.openCursor(1);
|
|
428
|
+
const toDelete = [];
|
|
429
|
+
|
|
430
|
+
request.onsuccess = (event) => {
|
|
431
|
+
const cursor = event.target.result;
|
|
432
|
+
|
|
433
|
+
if (cursor && toDelete.length < 100) {
|
|
434
|
+
toDelete.push(cursor.value.id);
|
|
435
|
+
cursor.continue();
|
|
436
|
+
} else {
|
|
437
|
+
// Delete collected IDs
|
|
438
|
+
toDelete.forEach((id) => {
|
|
439
|
+
store.delete(id);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
console.log(`[LogReporter] Cleaned ${toDelete.length} old logs due to quota`);
|
|
443
|
+
resolve();
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
request.onerror = () => {
|
|
448
|
+
console.error('[LogReporter] Failed to clean old logs:', request.error);
|
|
449
|
+
reject(request.error);
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Format logs as XML for XMDS submission
|
|
457
|
+
*
|
|
458
|
+
* Converts array of log objects to XML format expected by CMS.
|
|
459
|
+
*
|
|
460
|
+
* XML format:
|
|
461
|
+
* ```xml
|
|
462
|
+
* <logs>
|
|
463
|
+
* <log date="2026-02-10 12:00:00" category="PLAYER" type="error"
|
|
464
|
+
* message="Failed to load layout 123" />
|
|
465
|
+
* </logs>
|
|
466
|
+
* ```
|
|
467
|
+
*
|
|
468
|
+
* @param {Array} logs - Array of log objects from getLogsForSubmission()
|
|
469
|
+
* @returns {string} XML string for XMDS SubmitLog
|
|
470
|
+
*
|
|
471
|
+
* @example
|
|
472
|
+
* const logs = await reporter.getLogsForSubmission(100);
|
|
473
|
+
* const xml = formatLogs(logs);
|
|
474
|
+
* await xmds.submitLog(xml);
|
|
475
|
+
*/
|
|
476
|
+
export function formatLogs(logs) {
|
|
477
|
+
if (!logs || logs.length === 0) {
|
|
478
|
+
return '<logs></logs>';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const logElements = logs.map((logEntry) => {
|
|
482
|
+
// Format date as "YYYY-MM-DD HH:MM:SS"
|
|
483
|
+
const date = formatDateTime(logEntry.timestamp);
|
|
484
|
+
|
|
485
|
+
// Build attributes
|
|
486
|
+
const attrs = [
|
|
487
|
+
`date="${escapeXml(date)}"`,
|
|
488
|
+
`category="${escapeXml(logEntry.category)}"`,
|
|
489
|
+
`type="${escapeXml(logEntry.level)}"`,
|
|
490
|
+
`message="${escapeXml(logEntry.message)}"`
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
// Fault alert fields (triggers CMS dashboard alerts)
|
|
494
|
+
if (logEntry.alertType) {
|
|
495
|
+
attrs.push(`alertType="${escapeXml(logEntry.alertType)}"`);
|
|
496
|
+
}
|
|
497
|
+
if (logEntry.eventType) {
|
|
498
|
+
attrs.push(`eventType="${escapeXml(logEntry.eventType)}"`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return ` <log ${attrs.join(' ')} />`;
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
return `<logs>\n${logElements.join('\n')}\n</logs>`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Format Date object as "YYYY-MM-DD HH:MM:SS"
|
|
509
|
+
* @private
|
|
510
|
+
*/
|
|
511
|
+
function formatDateTime(date) {
|
|
512
|
+
if (!(date instanceof Date)) {
|
|
513
|
+
date = new Date(date);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const year = date.getFullYear();
|
|
517
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
518
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
519
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
520
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
521
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
522
|
+
|
|
523
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Escape XML special characters
|
|
528
|
+
* @private
|
|
529
|
+
*/
|
|
530
|
+
function escapeXml(str) {
|
|
531
|
+
if (typeof str !== 'string') {
|
|
532
|
+
return str;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return str
|
|
536
|
+
.replace(/&/g, '&')
|
|
537
|
+
.replace(/</g, '<')
|
|
538
|
+
.replace(/>/g, '>')
|
|
539
|
+
.replace(/"/g, '"')
|
|
540
|
+
.replace(/'/g, ''');
|
|
541
|
+
}
|