web_plsql 0.18.0 → 1.2.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 (74) hide show
  1. package/README.md +29 -9
  2. package/package.json +17 -7
  3. package/src/admin/client/charts.ts +598 -0
  4. package/src/admin/client/main.js +3 -0
  5. package/src/admin/client/tailwind.css +41 -0
  6. package/src/admin/favicon.svg +3 -0
  7. package/src/admin/index.html +529 -0
  8. package/src/admin/js/api.ts +170 -0
  9. package/src/admin/js/app.ts +614 -0
  10. package/src/admin/js/eslint.config.js +74 -0
  11. package/src/admin/js/schemas.ts +215 -0
  12. package/src/admin/js/templates/config.ts +147 -0
  13. package/src/admin/js/templates/errorRow.ts +21 -0
  14. package/src/admin/js/templates/index.ts +4 -0
  15. package/src/admin/js/templates/poolCard.ts +61 -0
  16. package/src/admin/js/templates/traceRow.ts +24 -0
  17. package/src/admin/js/tsconfig.json +24 -0
  18. package/src/admin/js/types.ts +336 -0
  19. package/src/admin/js/ui/components.ts +44 -0
  20. package/src/admin/js/ui/table.ts +173 -0
  21. package/src/admin/js/ui/theme.ts +93 -0
  22. package/src/admin/js/ui/views.ts +427 -0
  23. package/src/admin/js/util/format.ts +46 -0
  24. package/src/admin/js/util/metrics.ts +24 -0
  25. package/src/admin/lib/chart.bundle.css +1 -0
  26. package/src/admin/lib/chart.bundle.js +146 -0
  27. package/src/admin/style.css +1437 -0
  28. package/src/bin/load-test.js +202 -0
  29. package/src/handler/handlerAdmin.js +293 -0
  30. package/src/handler/plsql/cgi.js +1 -1
  31. package/src/handler/plsql/errorPage.js +31 -6
  32. package/src/handler/plsql/handlerPlSql.js +32 -6
  33. package/src/handler/plsql/owaPageStream.js +93 -0
  34. package/src/handler/plsql/parsePage.js +7 -3
  35. package/src/handler/plsql/procedure.js +221 -105
  36. package/src/handler/plsql/procedureNamed.js +34 -49
  37. package/src/handler/plsql/procedureSanitize.js +120 -45
  38. package/src/handler/plsql/procedureVariable.js +4 -2
  39. package/src/handler/plsql/request.js +8 -3
  40. package/src/handler/plsql/sendResponse.js +43 -5
  41. package/src/index.js +0 -1
  42. package/src/server/adminContext.js +23 -0
  43. package/src/server/config.js +1 -0
  44. package/src/server/server.js +130 -9
  45. package/src/types.js +7 -1
  46. package/src/util/cache.js +123 -0
  47. package/src/util/jsonLogger.js +47 -0
  48. package/src/util/shutdown.js +6 -2
  49. package/src/util/statsManager.js +368 -0
  50. package/src/util/trace.js +7 -6
  51. package/src/util/traceManager.js +68 -0
  52. package/src/version.js +1 -1
  53. package/types/admin/js/schemas.d.ts +384 -0
  54. package/types/admin/js/types.d.ts +312 -0
  55. package/types/handler/handlerAdmin.d.ts +79 -0
  56. package/types/handler/plsql/errorPage.d.ts +1 -0
  57. package/types/handler/plsql/handlerPlSql.d.ts +6 -1
  58. package/types/handler/plsql/owaPageStream.d.ts +28 -0
  59. package/types/handler/plsql/procedure.d.ts +4 -1
  60. package/types/handler/plsql/procedureNamed.d.ts +2 -5
  61. package/types/handler/plsql/procedureSanitize.d.ts +2 -5
  62. package/types/handler/plsql/procedureVariable.d.ts +1 -1
  63. package/types/handler/plsql/request.d.ts +3 -1
  64. package/types/handler/plsql/sendResponse.d.ts +1 -1
  65. package/types/index.d.ts +0 -1
  66. package/types/server/adminContext.d.ts +17 -0
  67. package/types/server/server.d.ts +5 -0
  68. package/types/types.d.ts +19 -1
  69. package/types/util/cache.d.ts +69 -0
  70. package/types/util/jsonLogger.d.ts +45 -0
  71. package/types/util/statsManager.d.ts +395 -0
  72. package/types/util/traceManager.d.ts +35 -0
  73. package/src/handler/handlerMetrics.js +0 -68
  74. package/types/handler/handlerMetrics.d.ts +0 -25
@@ -0,0 +1,614 @@
1
+ import {typedApi} from './api.js';
2
+ import {initCharts, updateCharts, hydrateHistory} from '../client/charts.js';
3
+ import {formatDuration, formatDateTime} from './util/format.js';
4
+ import {updateMinMaxMetrics} from './util/metrics.js';
5
+ import {initTheme} from './ui/theme.js';
6
+ import {refreshErrors, refreshTrace, refreshAccess, refreshConfig, refreshPools, refreshSystem, refreshStats} from './ui/views.js';
7
+ import {bindLoadingButton, withLoading} from './ui/components.js';
8
+ import type {State, SystemMetrics} from './types.js';
9
+
10
+ /**
11
+ * View icons mapping.
12
+ */
13
+ const viewIcons: Record<string, string> = {
14
+ overview: 'dashboard',
15
+ trace: 'bolt',
16
+ errors: 'error',
17
+ access: 'list_alt',
18
+ pools: 'database',
19
+ stats: 'query_stats',
20
+ config: 'settings',
21
+ system: 'terminal',
22
+ };
23
+
24
+ /**
25
+ * Application state.
26
+ */
27
+ const state: State = {
28
+ currentView: 'overview',
29
+ status: {},
30
+ maxHistoryPoints: 30,
31
+ lastRequestCount: 0,
32
+ lastErrorCount: 0,
33
+ lastUpdateTime: Date.now(),
34
+ lastBucketTimestamp: 0,
35
+ nextRefreshTimeout: null,
36
+ nextRefreshTime: 0,
37
+ countdownInterval: null,
38
+ history: {
39
+ labels: [],
40
+ requests: [],
41
+ avgResponseTimes: [],
42
+ p95ResponseTimes: [],
43
+ p99ResponseTimes: [],
44
+ cpuUsage: [],
45
+ memoryUsage: [],
46
+ poolUsage: {},
47
+ },
48
+ charts: {},
49
+ metricsMin: {},
50
+ metricsMax: {},
51
+ };
52
+
53
+ /**
54
+ * Clear cache for a pool.
55
+ *
56
+ * @param poolName - The pool name.
57
+ */
58
+ export async function clearCache(poolName: string): Promise<void> {
59
+ await typedApi.post('api/cache/clear', {poolName});
60
+ await updateStatus();
61
+ }
62
+
63
+ const STORAGE_KEYS = {
64
+ REFRESH_INTERVAL: 'admin_refresh_interval',
65
+ HISTORY_DURATION: 'admin_history_duration',
66
+ AUTO_REFRESH: 'admin_auto_refresh',
67
+ THEME: 'theme',
68
+ LAST_VIEW: 'admin_last_view',
69
+ };
70
+
71
+ /**
72
+ * Set the UI to offline state.
73
+ *
74
+ * @param error - The error object.
75
+ */
76
+ function setOfflineState(error: unknown): void {
77
+ console.error('Connection lost:', error);
78
+
79
+ const dot = document.getElementById('server-status-dot');
80
+ const text = document.getElementById('server-status-text');
81
+ if (dot) dot.className = 'dot offline';
82
+ if (text) text.textContent = 'OFFLINE';
83
+
84
+ const btnPause = document.getElementById('btn-pause');
85
+ if (btnPause) btnPause.style.display = 'none';
86
+
87
+ const btnResume = document.getElementById('btn-resume');
88
+ if (btnResume) btnResume.style.display = 'none';
89
+
90
+ // Update system view status if visible
91
+ const systemStatusDot = document.querySelector('#system-status .dot');
92
+ const systemStatusText = document.getElementById('system-status-text');
93
+ if (systemStatusDot) systemStatusDot.className = 'dot offline';
94
+ if (systemStatusText) systemStatusText.textContent = 'OFFLINE';
95
+
96
+ // Show offline modal
97
+ const modal = document.getElementById('offline-modal');
98
+ if (modal && modal.style.display !== 'flex') {
99
+ modal.style.display = 'flex';
100
+ }
101
+
102
+ // Schedule next attempt in 60s
103
+ scheduleNextRefresh(60000);
104
+ startCountdown(60000);
105
+ }
106
+
107
+ /**
108
+ * Start the countdown timer for the offline modal.
109
+ *
110
+ * @param durationMs - The duration in milliseconds.
111
+ */
112
+ function startCountdown(durationMs: number): void {
113
+ if (state.countdownInterval) clearInterval(state.countdownInterval);
114
+
115
+ const endTime = Date.now() + durationMs;
116
+ const el = document.getElementById('offline-countdown');
117
+ const btnText = document.getElementById('reconnect-text');
118
+
119
+ if (btnText) btnText.textContent = 'Try Reconnect';
120
+
121
+ const update = () => {
122
+ if (!el) return;
123
+ const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
124
+ el.textContent = `Retrying in ${remaining}s...`;
125
+ if (remaining <= 0 && state.countdownInterval) {
126
+ clearInterval(state.countdownInterval);
127
+ state.countdownInterval = null;
128
+ }
129
+ };
130
+
131
+ update();
132
+ state.countdownInterval = setInterval(update, 1000);
133
+ }
134
+
135
+ /**
136
+ * Stop the countdown timer.
137
+ */
138
+ function stopCountdown(): void {
139
+ if (state.countdownInterval) {
140
+ clearInterval(state.countdownInterval);
141
+ state.countdownInterval = null;
142
+ }
143
+ const el = document.getElementById('offline-countdown');
144
+ if (el) el.textContent = '';
145
+ }
146
+
147
+ /**
148
+ * Update server status.
149
+ */
150
+ async function updateStatus(): Promise<void> {
151
+ // Show spinner if manually triggered or retrying
152
+ const spinner = document.getElementById('reconnect-spinner');
153
+ const icon = document.getElementById('reconnect-icon');
154
+ const btnText = document.getElementById('reconnect-text');
155
+
156
+ if (spinner) spinner.style.display = 'inline-block';
157
+ if (icon) icon.style.display = 'none';
158
+ if (btnText) btnText.textContent = 'Connecting...';
159
+
160
+ try {
161
+ const now = Date.now();
162
+ const newStatus = await typedApi.getStatus();
163
+
164
+ // Check if we were offline
165
+ const modal = document.getElementById('offline-modal');
166
+ const wasOffline = modal?.style.display === 'flex';
167
+
168
+ if (wasOffline && modal) {
169
+ modal.style.display = 'none';
170
+ stopCountdown();
171
+ }
172
+
173
+ // Reset button state
174
+ if (spinner) spinner.style.display = 'none';
175
+ if (icon) icon.style.display = 'inline-block';
176
+ if (btnText) btnText.textContent = 'Try Reconnect';
177
+
178
+ // Hydrate history on first load or if history is provided
179
+ if (newStatus.history && state.history.labels.length === 0) {
180
+ hydrateHistory(state, newStatus.history);
181
+ }
182
+
183
+ state.status = newStatus;
184
+
185
+ if (!newStatus.metrics || !newStatus.pools) return;
186
+
187
+ // Use server provided interval stats if available
188
+ const latestBucket = newStatus.history?.[newStatus.history.length - 1];
189
+ const intervalSec = (newStatus.intervalMs ?? 5000) / 1000;
190
+ const reqPerSec = latestBucket ? latestBucket.requests / intervalSec : 0;
191
+ const avgResponseTime = latestBucket ? latestBucket.durationAvg : newStatus.metrics.avgResponseTime;
192
+
193
+ state.lastRequestCount = newStatus.metrics.requestCount;
194
+ state.lastErrorCount = newStatus.metrics.errorCount;
195
+ state.lastUpdateTime = now;
196
+
197
+ // Update Min/Max metrics
198
+ if (newStatus.system) {
199
+ const sys = newStatus.system;
200
+ const metrics: SystemMetrics = {
201
+ heapUsed: sys.memory.heapUsed,
202
+ heapTotal: sys.memory.heapTotal,
203
+ rss: sys.memory.rss,
204
+ external: sys.memory.external,
205
+ cpuUser: sys.cpu.user,
206
+ cpuSystem: sys.cpu.system,
207
+ };
208
+
209
+ updateMinMaxMetrics(metrics, state.metricsMin, state.metricsMax);
210
+ }
211
+
212
+ // Only update charts if we have a new bucket from the server
213
+ if (latestBucket && latestBucket.timestamp > state.lastBucketTimestamp) {
214
+ state.lastBucketTimestamp = latestBucket.timestamp;
215
+ const timeLabel = new Date(latestBucket.timestamp).toLocaleTimeString();
216
+ updateCharts(state, timeLabel, reqPerSec, avgResponseTime, newStatus.pools);
217
+ } else if (!latestBucket && state.history.labels.length === 0) {
218
+ // If we have no history yet, initialize with a zero point to avoid empty charts
219
+ const timeLabel = new Date().toLocaleTimeString();
220
+ updateCharts(state, timeLabel, 0, 0, newStatus.pools);
221
+ }
222
+
223
+ const sidebarVersion = document.getElementById('sidebar-version');
224
+ if (sidebarVersion) sidebarVersion.textContent = `v${newStatus.version}`;
225
+
226
+ const uptimeVal = document.getElementById('uptime-val');
227
+ if (uptimeVal) uptimeVal.textContent = formatDuration(newStatus.uptime);
228
+
229
+ const startTimeVal = document.getElementById('start-time-val');
230
+ if (startTimeVal) startTimeVal.textContent = `Started: ${formatDateTime(newStatus.startTime)}`;
231
+
232
+ const reqPerSecVal = document.getElementById('req-per-sec');
233
+ if (reqPerSecVal) reqPerSecVal.textContent = reqPerSec.toFixed(2);
234
+
235
+ const maxReqPerSecVal = document.getElementById('max-req-per-sec');
236
+ if (maxReqPerSecVal) maxReqPerSecVal.textContent = newStatus.metrics.maxRequestsPerSecond.toFixed(2);
237
+
238
+ const avgRespTimeVal = document.getElementById('avg-resp-time');
239
+ if (avgRespTimeVal) avgRespTimeVal.textContent = `${newStatus.metrics.avgResponseTime.toFixed(1)}ms`;
240
+
241
+ const maxRespTimeVal = document.getElementById('max-resp-time');
242
+ if (maxRespTimeVal) maxRespTimeVal.textContent = `${newStatus.metrics.maxResponseTime.toFixed(1)}ms`;
243
+
244
+ const errCount = document.getElementById('err-count');
245
+ if (errCount) errCount.textContent = newStatus.metrics.errorCount.toLocaleString();
246
+
247
+ // Update Cache Overview
248
+ let totalHits = 0;
249
+ let totalMisses = 0;
250
+ newStatus.pools.forEach((p) => {
251
+ if (p.cache) {
252
+ totalHits += p.cache.procedureName.hits + p.cache.argument.hits;
253
+ totalMisses += p.cache.procedureName.misses + p.cache.argument.misses;
254
+ }
255
+ });
256
+ const totalRequests = totalHits + totalMisses;
257
+ const hitRate = totalRequests > 0 ? Math.round((totalHits / totalRequests) * 100) : 0;
258
+
259
+ const cacheHitRateVal = document.getElementById('cache-hit-rate-val');
260
+ if (cacheHitRateVal) {
261
+ cacheHitRateVal.textContent = `${hitRate}%`;
262
+ cacheHitRateVal.style.color = hitRate > 80 ? 'var(--success)' : hitRate > 50 ? 'var(--warning)' : 'var(--danger)';
263
+ }
264
+ const cacheHitsVal = document.getElementById('cache-hits-val');
265
+ if (cacheHitsVal) cacheHitsVal.textContent = totalHits.toLocaleString();
266
+ const cacheMissesVal = document.getElementById('cache-misses-val');
267
+ if (cacheMissesVal) cacheMissesVal.textContent = totalMisses.toLocaleString();
268
+
269
+ const dot = document.getElementById('server-status-dot');
270
+ const text = document.getElementById('server-status-text');
271
+ if (dot) dot.className = 'dot ' + newStatus.status;
272
+ if (text) text.textContent = newStatus.status.toUpperCase();
273
+
274
+ const btnPause = document.getElementById('btn-pause');
275
+ if (btnPause) btnPause.style.display = newStatus.status === 'running' ? 'flex' : 'none';
276
+
277
+ const btnResume = document.getElementById('btn-resume');
278
+ if (btnResume) btnResume.style.display = newStatus.status === 'paused' ? 'flex' : 'none';
279
+
280
+ if (state.currentView === 'errors') await refreshErrors();
281
+ if (state.currentView === 'trace') await refreshTrace();
282
+ if (state.currentView === 'pools') refreshPools(newStatus);
283
+ if (state.currentView === 'stats') refreshStats(newStatus);
284
+ if (state.currentView === 'config') refreshConfig(state);
285
+ if (state.currentView === 'system') refreshSystem(newStatus, state);
286
+
287
+ // Schedule next refresh if auto-refresh is on
288
+ if (autoRefreshToggle?.checked && refreshIntervalSelect) {
289
+ const interval = parseInt(refreshIntervalSelect.value);
290
+ scheduleNextRefresh(interval);
291
+ }
292
+ } catch (err) {
293
+ const modal = document.getElementById('offline-modal');
294
+ // Only set offline state if not already shown to avoid resetting the timer repeatedly
295
+ if (modal?.style.display !== 'flex') {
296
+ setOfflineState(err);
297
+ } else {
298
+ // Already offline, reschedule next attempt
299
+ scheduleNextRefresh(60000);
300
+ startCountdown(60000);
301
+ // Reset button state on failure
302
+ if (spinner) spinner.style.display = 'none';
303
+ if (icon) icon.style.display = 'inline-block';
304
+ if (btnText) btnText.textContent = 'Try Reconnect';
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Schedule the next refresh using recursive setTimeout.
311
+ *
312
+ * @param delayMs - Delay in milliseconds.
313
+ */
314
+ function scheduleNextRefresh(delayMs: number): void {
315
+ if (state.nextRefreshTimeout) {
316
+ clearTimeout(state.nextRefreshTimeout);
317
+ state.nextRefreshTimeout = null;
318
+ }
319
+
320
+ state.nextRefreshTime = Date.now() + delayMs;
321
+ state.nextRefreshTimeout = setTimeout(() => {
322
+ void updateStatus();
323
+ }, delayMs);
324
+ }
325
+
326
+ /**
327
+ * Start the refresh timer (wrapper for compatibility).
328
+ */
329
+ function startRefreshTimer(): void {
330
+ const intervalSelect = document.getElementById('refresh-interval') as HTMLSelectElement | null;
331
+ if (intervalSelect) {
332
+ const interval = parseInt(intervalSelect.value);
333
+ scheduleNextRefresh(interval);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Stop the refresh timer.
339
+ */
340
+ function stopRefreshTimer(): void {
341
+ if (state.nextRefreshTimeout) {
342
+ clearTimeout(state.nextRefreshTimeout);
343
+ state.nextRefreshTimeout = null;
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Update history points labels to be time-based.
349
+ */
350
+ function updateHistoryLabels(): void {
351
+ const historySelect = document.getElementById('chart-history-points') as HTMLSelectElement | null;
352
+ if (!historySelect) return;
353
+
354
+ Array.from(historySelect.options).forEach((option) => {
355
+ const durationSeconds = parseInt(option.value);
356
+
357
+ let timeStr = '';
358
+ if (durationSeconds < 60) {
359
+ timeStr = `${durationSeconds}s`;
360
+ } else if (durationSeconds < 3600) {
361
+ timeStr = `${Math.round(durationSeconds / 60)} min`;
362
+ } else {
363
+ const hours = durationSeconds / 3600;
364
+ timeStr = hours === Math.round(hours) ? `${hours} hour${hours > 1 ? 's' : ''}` : `${hours.toFixed(1)} hours`;
365
+ }
366
+ option.textContent = `Last ${timeStr}`;
367
+ });
368
+ }
369
+
370
+ // Navigation
371
+ document.querySelectorAll('nav button').forEach((btnEl) => {
372
+ const btn = btnEl as HTMLButtonElement;
373
+ btn.onclick = async () => {
374
+ try {
375
+ const view = btn.dataset.view;
376
+ if (!view) return;
377
+ state.currentView = view;
378
+ localStorage.setItem(STORAGE_KEYS.LAST_VIEW, view);
379
+ document.querySelectorAll('.view').forEach((vEl) => {
380
+ const v = vEl as HTMLElement;
381
+ v.style.display = 'none';
382
+ });
383
+ const viewEl = document.getElementById('view-' + view);
384
+ if (viewEl) viewEl.style.display = 'block';
385
+ document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
386
+ btn.classList.add('active');
387
+ const viewTitleEl = document.getElementById('view-title');
388
+ if (viewTitleEl) {
389
+ // Get text content excluding the icon span
390
+ const textNodes = Array.from(btn.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE);
391
+ const btnText = textNodes.map((node) => node.textContent?.trim()).join('');
392
+ const icon = viewIcons[view] || 'chevron_right';
393
+ viewTitleEl.innerHTML = `<span class="material-symbols-rounded">${icon}</span>${btnText}`;
394
+ }
395
+
396
+ if (view === 'errors') await refreshErrors();
397
+ if (view === 'trace') await refreshTrace();
398
+ if (view === 'pools') refreshPools(state.status);
399
+ if (view === 'stats') refreshStats(state.status);
400
+ if (view === 'config') refreshConfig(state);
401
+ if (view === 'system') refreshSystem(state.status, state);
402
+ } catch (err) {
403
+ setOfflineState(err);
404
+ }
405
+ };
406
+ });
407
+
408
+ const autoRefreshToggle = document.getElementById('auto-refresh-toggle') as HTMLInputElement | null;
409
+ if (autoRefreshToggle) {
410
+ autoRefreshToggle.onchange = (e: Event) => {
411
+ const target = e.target as HTMLInputElement;
412
+ localStorage.setItem(STORAGE_KEYS.AUTO_REFRESH, String(target.checked));
413
+ if (target.checked) startRefreshTimer();
414
+ else stopRefreshTimer();
415
+ };
416
+ }
417
+
418
+ const refreshIntervalSelect = document.getElementById('refresh-interval') as HTMLSelectElement | null;
419
+ if (refreshIntervalSelect) {
420
+ refreshIntervalSelect.onchange = () => {
421
+ localStorage.setItem(STORAGE_KEYS.REFRESH_INTERVAL, refreshIntervalSelect.value);
422
+
423
+ // Recalculate maxHistoryPoints for the new interval
424
+ const intervalMs = parseInt(refreshIntervalSelect.value);
425
+ const durationSeconds = parseInt(chartHistorySelect?.value ?? '60');
426
+ state.maxHistoryPoints = Math.max(1, Math.floor(durationSeconds / (intervalMs / 1000)));
427
+
428
+ // Clear history to force re-hydration from server on next update
429
+ state.history.labels = [];
430
+ state.history.requests = [];
431
+ state.history.avgResponseTimes = [];
432
+ state.history.cpuUsage = [];
433
+ state.history.memoryUsage = [];
434
+ state.history.poolUsage = {};
435
+
436
+ updateHistoryLabels();
437
+ refreshSystem(state.status, state);
438
+ void updateStatus();
439
+ };
440
+ }
441
+
442
+ const chartHistorySelect = document.getElementById('chart-history-points') as HTMLSelectElement | null;
443
+ if (chartHistorySelect) {
444
+ chartHistorySelect.onchange = () => {
445
+ localStorage.setItem(STORAGE_KEYS.HISTORY_DURATION, chartHistorySelect.value);
446
+ const intervalSelect = document.getElementById('refresh-interval') as HTMLSelectElement | null;
447
+ if (!intervalSelect) return;
448
+ const intervalMs = parseInt(intervalSelect.value);
449
+ const durationSeconds = parseInt(chartHistorySelect.value);
450
+ state.maxHistoryPoints = Math.max(1, Math.floor(durationSeconds / (intervalMs / 1000)));
451
+
452
+ // Clear history to force re-hydration from server on next update
453
+ state.history.labels = [];
454
+ state.history.requests = [];
455
+ state.history.avgResponseTimes = [];
456
+ state.history.cpuUsage = [];
457
+ state.history.memoryUsage = [];
458
+ state.history.poolUsage = {};
459
+
460
+ void updateStatus();
461
+ };
462
+ }
463
+
464
+ const manualRefreshBtn = document.getElementById('manual-refresh') as HTMLButtonElement | null;
465
+ if (manualRefreshBtn) {
466
+ manualRefreshBtn.onclick = () => {
467
+ void withLoading(manualRefreshBtn, updateStatus);
468
+ };
469
+ }
470
+
471
+ const errorFilterInput = document.getElementById('error-filter') as HTMLInputElement | null;
472
+ if (errorFilterInput) {
473
+ errorFilterInput.onkeydown = (e) => {
474
+ if (e.key === 'Enter') void refreshErrors();
475
+ };
476
+ }
477
+
478
+ bindLoadingButton('error-load-btn', refreshErrors);
479
+ bindLoadingButton('trace-load-btn', refreshTrace);
480
+ bindLoadingButton('access-load-btn', refreshAccess);
481
+
482
+ const traceFilterInput = document.getElementById('trace-filter') as HTMLInputElement | null;
483
+ if (traceFilterInput) {
484
+ traceFilterInput.onkeydown = (e) => {
485
+ if (e.key === 'Enter') void refreshTrace();
486
+ };
487
+ }
488
+
489
+ const traceClearBtn = document.getElementById('trace-clear-btn') as HTMLButtonElement | null;
490
+ if (traceClearBtn) {
491
+ traceClearBtn.onclick = async () => {
492
+ if (confirm('Are you sure you want to clear all traces?')) {
493
+ await withLoading(traceClearBtn, async () => {
494
+ await typedApi.clearTraces();
495
+ await refreshTrace();
496
+ });
497
+ }
498
+ };
499
+ }
500
+
501
+ const traceStatusToggle = document.getElementById('trace-status-toggle') as HTMLButtonElement | null;
502
+ if (traceStatusToggle) {
503
+ traceStatusToggle.onclick = async () => {
504
+ await withLoading(traceStatusToggle, async () => {
505
+ const {enabled: currentEnabled} = await typedApi.getTraceStatus();
506
+ await typedApi.toggleTrace(!currentEnabled);
507
+ await refreshTrace();
508
+ });
509
+ };
510
+ }
511
+
512
+ const accessFilterInput = document.getElementById('access-filter') as HTMLInputElement | null;
513
+ if (accessFilterInput) {
514
+ accessFilterInput.onkeydown = (e) => {
515
+ if (e.key === 'Enter') void refreshAccess();
516
+ };
517
+ }
518
+
519
+ const statsRowLimitSelect = document.getElementById('stats-row-limit') as HTMLSelectElement | null;
520
+ if (statsRowLimitSelect) {
521
+ statsRowLimitSelect.onchange = () => {
522
+ refreshStats(state.status);
523
+ };
524
+ }
525
+
526
+ // Danger Zone Actions
527
+ bindLoadingButton('btn-pause', async () => {
528
+ if (confirm('Are you sure you want to pause the server?')) {
529
+ await typedApi.post('api/server/pause');
530
+ await updateStatus();
531
+ }
532
+ });
533
+
534
+ bindLoadingButton('btn-resume', async () => {
535
+ await typedApi.post('api/server/resume');
536
+ await updateStatus();
537
+ });
538
+
539
+ bindLoadingButton('btn-stop', async () => {
540
+ if (confirm('Are you sure you want to STOP the server? This action cannot be undone from the web interface.')) {
541
+ await typedApi.post('api/server/stop');
542
+ alert('Server stop requested. The interface will now become unresponsive.');
543
+ }
544
+ });
545
+
546
+ bindLoadingButton('btn-clear-all-cache', async () => {
547
+ if (confirm('Are you sure you want to clear ALL caches?')) {
548
+ await typedApi.post('api/cache/clear');
549
+ await updateStatus();
550
+ }
551
+ });
552
+
553
+ const btnReconnect = document.getElementById('btn-reconnect') as HTMLButtonElement | null;
554
+ if (btnReconnect) {
555
+ btnReconnect.onclick = () => {
556
+ void withLoading(btnReconnect, updateStatus);
557
+ };
558
+ }
559
+
560
+ // Close modal on escape key
561
+ window.addEventListener('keydown', (e) => {
562
+ if (e.key === 'Escape') {
563
+ const errorModal = document.getElementById('error-modal');
564
+ if (errorModal) errorModal.style.display = 'none';
565
+ const traceModal = document.getElementById('trace-modal');
566
+ if (traceModal) traceModal.style.display = 'none';
567
+ }
568
+ });
569
+
570
+ // Initialize
571
+
572
+ // Load persisted settings
573
+ const savedRefreshInterval = localStorage.getItem(STORAGE_KEYS.REFRESH_INTERVAL);
574
+ if (savedRefreshInterval && refreshIntervalSelect) {
575
+ refreshIntervalSelect.value = savedRefreshInterval;
576
+ }
577
+
578
+ const savedHistoryDuration = localStorage.getItem(STORAGE_KEYS.HISTORY_DURATION);
579
+ if (savedHistoryDuration && chartHistorySelect) {
580
+ chartHistorySelect.value = savedHistoryDuration;
581
+ }
582
+
583
+ const savedAutoRefresh = localStorage.getItem(STORAGE_KEYS.AUTO_REFRESH);
584
+ if (savedAutoRefresh !== null && autoRefreshToggle) {
585
+ autoRefreshToggle.checked = savedAutoRefresh === 'true';
586
+ }
587
+
588
+ initTheme(state);
589
+ initCharts(state);
590
+ updateHistoryLabels();
591
+
592
+ // Apply initial maxHistoryPoints based on loaded settings
593
+ if (refreshIntervalSelect && chartHistorySelect) {
594
+ const intervalMs = parseInt(refreshIntervalSelect.value);
595
+ const durationSeconds = parseInt(chartHistorySelect.value);
596
+ state.maxHistoryPoints = Math.max(1, Math.floor(durationSeconds / (intervalMs / 1000)));
597
+ }
598
+
599
+ // Restore last view
600
+ const lastView = localStorage.getItem(STORAGE_KEYS.LAST_VIEW);
601
+ if (lastView) {
602
+ const btn = document.querySelector<HTMLButtonElement>(`nav button[data-view="${lastView}"]`);
603
+ if (btn) {
604
+ btn.click();
605
+ }
606
+ }
607
+
608
+ void updateStatus()
609
+ .then(() => {
610
+ if (autoRefreshToggle?.checked) {
611
+ startRefreshTimer();
612
+ }
613
+ })
614
+ .catch(console.error);
@@ -0,0 +1,74 @@
1
+ // @ts-check
2
+
3
+ import eslint from '@eslint/js';
4
+ import {defineConfig} from 'eslint/config';
5
+ import tseslint from 'typescript-eslint';
6
+ import globals from 'globals';
7
+
8
+ export default defineConfig([
9
+ {
10
+ ignores: ['**/.*', 'lib/**', 'node_modules/**', '**/lib/**'],
11
+ },
12
+
13
+ {
14
+ linterOptions: {
15
+ reportUnusedDisableDirectives: 'error',
16
+ reportUnusedInlineConfigs: 'error',
17
+ },
18
+ },
19
+
20
+ eslint.configs.recommended,
21
+ ...tseslint.configs.strictTypeChecked,
22
+ ...tseslint.configs.stylisticTypeChecked,
23
+
24
+ {
25
+ languageOptions: {
26
+ globals: {
27
+ ...globals.browser,
28
+ },
29
+ parserOptions: {
30
+ projectService: true,
31
+ tsconfigRootDir: import.meta.dirname,
32
+ },
33
+ },
34
+ rules: {
35
+ // Allow empty functions for chart update stubs
36
+ '@typescript-eslint/no-empty-function': 'off',
37
+
38
+ // Disable JSDoc requirements in TypeScript files
39
+ 'jsdoc/require-param': 'off',
40
+ 'jsdoc/require-param-type': 'off',
41
+ 'jsdoc/require-returns-type': 'off',
42
+ 'jsdoc/require-param-description': 'off',
43
+ 'jsdoc/require-property-description': 'off',
44
+ 'jsdoc/require-returns-description': 'off',
45
+
46
+ // Allow toString() for logging/debugging
47
+ '@typescript-eslint/no-base-to-string': 'off',
48
+
49
+ // Relaxed rules for template literals
50
+ '@typescript-eslint/restrict-template-expressions': 'off',
51
+
52
+ // Consistent type definitions
53
+ '@typescript-eslint/consistent-type-definitions': 'off',
54
+
55
+ // Unused vars
56
+ '@typescript-eslint/no-unused-vars': [
57
+ 'warn',
58
+ {
59
+ caughtErrors: 'none',
60
+ argsIgnorePattern: '^_',
61
+ },
62
+ ],
63
+
64
+ // Deprecation warnings
65
+ '@typescript-eslint/no-deprecated': 'warn',
66
+
67
+ // Void expression
68
+ '@typescript-eslint/no-confusing-void-expression': 'off',
69
+
70
+ // Unnecessary conditions
71
+ '@typescript-eslint/no-unnecessary-condition': 'off',
72
+ },
73
+ },
74
+ ]);