web_plsql 1.0.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 (51) hide show
  1. package/README.md +5 -5
  2. package/package.json +5 -5
  3. package/src/admin/client/charts.ts +303 -4
  4. package/src/admin/index.html +283 -69
  5. package/src/admin/js/api.ts +95 -20
  6. package/src/admin/js/app.ts +460 -152
  7. package/src/admin/js/schemas.ts +76 -14
  8. package/src/admin/js/templates/config.ts +10 -9
  9. package/src/admin/js/templates/errorRow.ts +8 -5
  10. package/src/admin/js/templates/index.ts +1 -0
  11. package/src/admin/js/templates/poolCard.ts +6 -6
  12. package/src/admin/js/templates/traceRow.ts +24 -0
  13. package/src/admin/js/types.ts +132 -19
  14. package/src/admin/js/ui/components.ts +44 -0
  15. package/src/admin/js/ui/table.ts +173 -0
  16. package/src/admin/js/ui/views.ts +311 -48
  17. package/src/admin/js/util/format.ts +22 -3
  18. package/src/admin/js/util/metrics.ts +24 -0
  19. package/src/admin/lib/chart.bundle.css +1 -0
  20. package/src/admin/lib/chart.bundle.js +54 -47
  21. package/src/admin/style.css +143 -27
  22. package/src/handler/handlerAdmin.js +121 -26
  23. package/src/handler/plsql/errorPage.js +0 -4
  24. package/src/handler/plsql/owaPageStream.js +93 -0
  25. package/src/handler/plsql/procedure.js +191 -121
  26. package/src/handler/plsql/procedureNamed.js +17 -3
  27. package/src/handler/plsql/procedureSanitize.js +24 -0
  28. package/src/handler/plsql/procedureVariable.js +2 -0
  29. package/src/handler/plsql/sendResponse.js +41 -3
  30. package/src/index.js +0 -1
  31. package/src/server/adminContext.js +23 -0
  32. package/src/server/server.js +83 -68
  33. package/src/types.js +1 -1
  34. package/src/util/statsManager.js +368 -0
  35. package/src/util/trace.js +2 -1
  36. package/src/util/traceManager.js +68 -0
  37. package/src/version.js +1 -1
  38. package/types/admin/js/schemas.d.ts +384 -0
  39. package/types/admin/js/types.d.ts +312 -0
  40. package/types/handler/handlerAdmin.d.ts +70 -0
  41. package/types/handler/plsql/owaPageStream.d.ts +28 -0
  42. package/types/handler/plsql/procedure.d.ts +1 -0
  43. package/types/index.d.ts +0 -1
  44. package/types/server/adminContext.d.ts +17 -0
  45. package/types/server/server.d.ts +2 -19
  46. package/types/types.d.ts +1 -1
  47. package/types/util/statsManager.d.ts +395 -0
  48. package/types/util/traceManager.d.ts +35 -0
  49. package/src/admin/lib/assets/main-zpdhQ1gD.css +0 -1
  50. package/src/handler/handlerMetrics.js +0 -68
  51. package/types/handler/handlerMetrics.d.ts +0 -25
@@ -1,19 +1,22 @@
1
1
  import {typedApi} from './api.js';
2
- import {initCharts, updateCharts} from '../client/charts.js';
2
+ import {initCharts, updateCharts, hydrateHistory} from '../client/charts.js';
3
3
  import {formatDuration, formatDateTime} from './util/format.js';
4
+ import {updateMinMaxMetrics} from './util/metrics.js';
4
5
  import {initTheme} from './ui/theme.js';
5
- import {refreshErrors, refreshAccess, refreshConfig, refreshPools, refreshSystem} from './ui/views.js';
6
- import type {State} from './types.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';
7
9
 
8
10
  /**
9
11
  * View icons mapping.
10
12
  */
11
13
  const viewIcons: Record<string, string> = {
12
14
  overview: 'dashboard',
15
+ trace: 'bolt',
13
16
  errors: 'error',
14
17
  access: 'list_alt',
15
- cache: 'cached',
16
18
  pools: 'database',
19
+ stats: 'query_stats',
17
20
  config: 'settings',
18
21
  system: 'terminal',
19
22
  };
@@ -28,14 +31,23 @@ const state: State = {
28
31
  lastRequestCount: 0,
29
32
  lastErrorCount: 0,
30
33
  lastUpdateTime: Date.now(),
31
- refreshTimer: null,
34
+ lastBucketTimestamp: 0,
35
+ nextRefreshTimeout: null,
36
+ nextRefreshTime: 0,
37
+ countdownInterval: null,
32
38
  history: {
33
39
  labels: [],
34
40
  requests: [],
35
41
  avgResponseTimes: [],
42
+ p95ResponseTimes: [],
43
+ p99ResponseTimes: [],
44
+ cpuUsage: [],
45
+ memoryUsage: [],
36
46
  poolUsage: {},
37
47
  },
38
48
  charts: {},
49
+ metricsMin: {},
50
+ metricsMax: {},
39
51
  };
40
52
 
41
53
  /**
@@ -48,101 +60,277 @@ export async function clearCache(poolName: string): Promise<void> {
48
60
  await updateStatus();
49
61
  }
50
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
+
51
147
  /**
52
148
  * Update server status.
53
149
  */
54
150
  async function updateStatus(): Promise<void> {
55
- const now = Date.now();
56
- const newStatus = await typedApi.getStatus();
57
- state.status = newStatus;
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
+ }
58
172
 
59
- if (!newStatus.metrics || !newStatus.pools) return;
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';
60
177
 
61
- const deltaSec = (now - state.lastUpdateTime) / 1000;
62
- const reqCountDelta = newStatus.metrics.requestCount - state.lastRequestCount;
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
+ }
63
182
 
64
- const reqPerSec = deltaSec > 0 ? reqCountDelta / deltaSec : 0;
65
- const avgResponseTime = newStatus.metrics.avgResponseTime;
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
+ }
66
211
 
67
- state.lastRequestCount = newStatus.metrics.requestCount;
68
- state.lastErrorCount = newStatus.metrics.errorCount;
69
- state.lastUpdateTime = now;
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
+ }
70
222
 
71
- const timeLabel = new Date().toLocaleTimeString();
72
- updateCharts(state, timeLabel, reqPerSec, avgResponseTime, newStatus.pools);
223
+ const sidebarVersion = document.getElementById('sidebar-version');
224
+ if (sidebarVersion) sidebarVersion.textContent = `v${newStatus.version}`;
73
225
 
74
- const sidebarVersion = document.getElementById('sidebar-version');
75
- if (sidebarVersion) sidebarVersion.textContent = `v${newStatus.version}`;
226
+ const uptimeVal = document.getElementById('uptime-val');
227
+ if (uptimeVal) uptimeVal.textContent = formatDuration(newStatus.uptime);
76
228
 
77
- const uptimeVal = document.getElementById('uptime-val');
78
- if (uptimeVal) uptimeVal.textContent = formatDuration(newStatus.uptime);
229
+ const startTimeVal = document.getElementById('start-time-val');
230
+ if (startTimeVal) startTimeVal.textContent = `Started: ${formatDateTime(newStatus.startTime)}`;
79
231
 
80
- const startTimeVal = document.getElementById('start-time-val');
81
- if (startTimeVal) startTimeVal.textContent = `Started: ${formatDateTime(newStatus.startTime)}`;
232
+ const reqPerSecVal = document.getElementById('req-per-sec');
233
+ if (reqPerSecVal) reqPerSecVal.textContent = reqPerSec.toFixed(2);
82
234
 
83
- const reqCount = document.getElementById('req-count');
84
- if (reqCount) reqCount.textContent = newStatus.metrics.requestCount.toLocaleString();
235
+ const maxReqPerSecVal = document.getElementById('max-req-per-sec');
236
+ if (maxReqPerSecVal) maxReqPerSecVal.textContent = newStatus.metrics.maxRequestsPerSecond.toFixed(2);
85
237
 
86
- const reqPerSecVal = document.getElementById('req-per-sec');
87
- if (reqPerSecVal) reqPerSecVal.textContent = `${reqPerSec.toFixed(2)} req/s`;
238
+ const avgRespTimeVal = document.getElementById('avg-resp-time');
239
+ if (avgRespTimeVal) avgRespTimeVal.textContent = `${newStatus.metrics.avgResponseTime.toFixed(1)}ms`;
88
240
 
89
- const avgRespTimeVal = document.getElementById('avg-resp-time');
90
- if (avgRespTimeVal) avgRespTimeVal.textContent = `Avg: ${avgResponseTime.toFixed(1)}ms`;
241
+ const maxRespTimeVal = document.getElementById('max-resp-time');
242
+ if (maxRespTimeVal) maxRespTimeVal.textContent = `${newStatus.metrics.maxResponseTime.toFixed(1)}ms`;
91
243
 
92
- const errCount = document.getElementById('err-count');
93
- if (errCount) errCount.textContent = newStatus.metrics.errorCount.toLocaleString();
244
+ const errCount = document.getElementById('err-count');
245
+ if (errCount) errCount.textContent = newStatus.metrics.errorCount.toLocaleString();
94
246
 
95
- // Update Cache Overview
96
- const caches = await typedApi.getCache();
97
- let totalHits = 0;
98
- let totalMisses = 0;
99
- caches.forEach((c) => {
100
- totalHits += c.procedureNameCache.stats.hits + c.argumentCache.stats.hits;
101
- totalMisses += c.procedureNameCache.stats.misses + c.argumentCache.stats.misses;
102
- });
103
- const totalRequests = totalHits + totalMisses;
104
- const hitRate = totalRequests > 0 ? Math.round((totalHits / totalRequests) * 100) : 0;
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;
105
258
 
106
- const cacheHitRateVal = document.getElementById('cache-hit-rate-val');
107
- if (cacheHitRateVal) {
108
- cacheHitRateVal.textContent = `${hitRate}%`;
109
- cacheHitRateVal.style.color = hitRate > 80 ? 'var(--success)' : hitRate > 50 ? 'var(--warning)' : 'var(--danger)';
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
+ }
110
306
  }
111
- const cacheHitsVal = document.getElementById('cache-hits-val');
112
- if (cacheHitsVal) cacheHitsVal.textContent = totalHits.toLocaleString();
113
- const cacheMissesVal = document.getElementById('cache-misses-val');
114
- if (cacheMissesVal) cacheMissesVal.textContent = totalMisses.toLocaleString();
115
-
116
- const dot = document.getElementById('server-status-dot');
117
- const text = document.getElementById('server-status-text');
118
- if (dot) dot.className = 'dot ' + newStatus.status;
119
- if (text) text.textContent = newStatus.status.toUpperCase();
120
-
121
- const btnPause = document.getElementById('btn-pause');
122
- if (btnPause) btnPause.style.display = newStatus.status === 'running' ? 'flex' : 'none';
307
+ }
123
308
 
124
- const btnResume = document.getElementById('btn-resume');
125
- if (btnResume) btnResume.style.display = newStatus.status === 'paused' ? 'flex' : 'none';
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
+ }
126
319
 
127
- if (state.currentView === 'errors') await refreshErrors();
128
- if (state.currentView === 'access') await refreshAccess();
129
- if (state.currentView === 'pools') refreshPools(newStatus);
130
- if (state.currentView === 'config') refreshConfig(state);
131
- if (state.currentView === 'system') refreshSystem(newStatus);
320
+ state.nextRefreshTime = Date.now() + delayMs;
321
+ state.nextRefreshTimeout = setTimeout(() => {
322
+ void updateStatus();
323
+ }, delayMs);
132
324
  }
133
325
 
134
326
  /**
135
- * Start the refresh timer.
327
+ * Start the refresh timer (wrapper for compatibility).
136
328
  */
137
329
  function startRefreshTimer(): void {
138
- stopRefreshTimer();
139
- const checkbox = document.getElementById('auto-refresh-toggle') as HTMLInputElement | null;
140
330
  const intervalSelect = document.getElementById('refresh-interval') as HTMLSelectElement | null;
141
- if (checkbox?.checked && intervalSelect) {
331
+ if (intervalSelect) {
142
332
  const interval = parseInt(intervalSelect.value);
143
- state.refreshTimer = setInterval(() => {
144
- void updateStatus();
145
- }, interval);
333
+ scheduleNextRefresh(interval);
146
334
  }
147
335
  }
148
336
 
@@ -150,9 +338,9 @@ function startRefreshTimer(): void {
150
338
  * Stop the refresh timer.
151
339
  */
152
340
  function stopRefreshTimer(): void {
153
- if (state.refreshTimer) {
154
- clearInterval(state.refreshTimer);
155
- state.refreshTimer = null;
341
+ if (state.nextRefreshTimeout) {
342
+ clearTimeout(state.nextRefreshTimeout);
343
+ state.nextRefreshTimeout = null;
156
344
  }
157
345
  }
158
346
 
@@ -160,26 +348,20 @@ function stopRefreshTimer(): void {
160
348
  * Update history points labels to be time-based.
161
349
  */
162
350
  function updateHistoryLabels(): void {
163
- const intervalSelect = document.getElementById('refresh-interval') as HTMLSelectElement | null;
164
351
  const historySelect = document.getElementById('chart-history-points') as HTMLSelectElement | null;
165
- if (!intervalSelect || !historySelect) return;
166
-
167
- const intervalMs = parseInt(intervalSelect.value);
168
- const points = [30, 60, 120];
352
+ if (!historySelect) return;
169
353
 
170
- points.forEach((pts, idx) => {
171
- const option = historySelect.options[idx];
172
- if (!option) return;
354
+ Array.from(historySelect.options).forEach((option) => {
355
+ const durationSeconds = parseInt(option.value);
173
356
 
174
- const totalSeconds = (pts * intervalMs) / 1000;
175
357
  let timeStr = '';
176
- if (totalSeconds < 60) {
177
- timeStr = `${totalSeconds}s`;
178
- } else if (totalSeconds < 3600) {
179
- timeStr = `${Math.round(totalSeconds / 60)} min`;
358
+ if (durationSeconds < 60) {
359
+ timeStr = `${durationSeconds}s`;
360
+ } else if (durationSeconds < 3600) {
361
+ timeStr = `${Math.round(durationSeconds / 60)} min`;
180
362
  } else {
181
- const hours = totalSeconds / 3600;
182
- timeStr = hours === Math.round(hours) ? `${hours} hours` : `${hours.toFixed(1)} hours`;
363
+ const hours = durationSeconds / 3600;
364
+ timeStr = hours === Math.round(hours) ? `${hours} hour${hours > 1 ? 's' : ''}` : `${hours.toFixed(1)} hours`;
183
365
  }
184
366
  option.textContent = `Last ${timeStr}`;
185
367
  });
@@ -189,31 +371,37 @@ function updateHistoryLabels(): void {
189
371
  document.querySelectorAll('nav button').forEach((btnEl) => {
190
372
  const btn = btnEl as HTMLButtonElement;
191
373
  btn.onclick = async () => {
192
- const view = btn.dataset.view;
193
- if (!view) return;
194
- state.currentView = view;
195
- document.querySelectorAll('.view').forEach((vEl) => {
196
- const v = vEl as HTMLElement;
197
- v.style.display = 'none';
198
- });
199
- const viewEl = document.getElementById('view-' + view);
200
- if (viewEl) viewEl.style.display = 'block';
201
- document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
202
- btn.classList.add('active');
203
- const viewTitleEl = document.getElementById('view-title');
204
- if (viewTitleEl) {
205
- // Get text content excluding the icon span
206
- const textNodes = Array.from(btn.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE);
207
- const btnText = textNodes.map((node) => node.textContent?.trim()).join('');
208
- const icon = viewIcons[view] || 'chevron_right';
209
- viewTitleEl.innerHTML = `<span class="material-symbols-rounded">${icon}</span>${btnText}`;
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);
210
404
  }
211
-
212
- if (view === 'errors') await refreshErrors();
213
- if (view === 'access') await refreshAccess();
214
- if (view === 'pools') refreshPools(state.status);
215
- if (view === 'config') refreshConfig(state);
216
- if (view === 'system') refreshSystem(state.status);
217
405
  };
218
406
  });
219
407
 
@@ -221,6 +409,7 @@ const autoRefreshToggle = document.getElementById('auto-refresh-toggle') as HTML
221
409
  if (autoRefreshToggle) {
222
410
  autoRefreshToggle.onchange = (e: Event) => {
223
411
  const target = e.target as HTMLInputElement;
412
+ localStorage.setItem(STORAGE_KEYS.AUTO_REFRESH, String(target.checked));
224
413
  if (target.checked) startRefreshTimer();
225
414
  else stopRefreshTimer();
226
415
  };
@@ -229,78 +418,197 @@ if (autoRefreshToggle) {
229
418
  const refreshIntervalSelect = document.getElementById('refresh-interval') as HTMLSelectElement | null;
230
419
  if (refreshIntervalSelect) {
231
420
  refreshIntervalSelect.onchange = () => {
232
- startRefreshTimer();
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
+
233
436
  updateHistoryLabels();
234
- refreshSystem(state.status);
437
+ refreshSystem(state.status, state);
438
+ void updateStatus();
235
439
  };
236
440
  }
237
441
 
238
442
  const chartHistorySelect = document.getElementById('chart-history-points') as HTMLSelectElement | null;
239
443
  if (chartHistorySelect) {
240
444
  chartHistorySelect.onchange = () => {
241
- state.maxHistoryPoints = parseInt(chartHistorySelect.value);
242
- // Trim history if needed
243
- while (state.history.labels.length > state.maxHistoryPoints) {
244
- state.history.labels.shift();
245
- state.history.requests.shift();
246
- state.history.avgResponseTimes.shift();
247
- Object.values(state.history.poolUsage).forEach((u) => u.shift());
248
- }
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
+
249
460
  void updateStatus();
250
461
  };
251
462
  }
252
463
 
253
- const manualRefreshBtn = document.getElementById('manual-refresh');
464
+ const manualRefreshBtn = document.getElementById('manual-refresh') as HTMLButtonElement | null;
254
465
  if (manualRefreshBtn) {
255
466
  manualRefreshBtn.onclick = () => {
256
- void updateStatus();
467
+ void withLoading(manualRefreshBtn, updateStatus);
257
468
  };
258
469
  }
259
470
 
260
- // Server controls
261
- const btnPause = document.getElementById('btn-pause');
262
- if (btnPause) {
263
- btnPause.onclick = () => {
264
- void typedApi.post('api/server/pause').then(() => {
265
- void updateStatus();
266
- });
471
+ const errorFilterInput = document.getElementById('error-filter') as HTMLInputElement | null;
472
+ if (errorFilterInput) {
473
+ errorFilterInput.onkeydown = (e) => {
474
+ if (e.key === 'Enter') void refreshErrors();
267
475
  };
268
476
  }
269
477
 
270
- const btnResume = document.getElementById('btn-resume');
271
- if (btnResume) {
272
- btnResume.onclick = () => {
273
- void typedApi.post('api/server/resume').then(() => {
274
- void updateStatus();
275
- });
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();
276
486
  };
277
487
  }
278
488
 
279
- const btnStop = document.getElementById('btn-stop');
280
- if (btnStop) {
281
- btnStop.onclick = () => {
282
- if (confirm('Stop the server?')) {
283
- void typedApi.post('api/server/stop');
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
+ });
284
497
  }
285
498
  };
286
499
  }
287
500
 
288
- const btnClearAllCache = document.getElementById('btn-clear-all-cache');
289
- if (btnClearAllCache) {
290
- btnClearAllCache.onclick = async () => {
291
- if (confirm('Are you sure you want to clear all caches in all pools? This action cannot be undone.')) {
292
- await typedApi.post('api/cache/clear', {});
293
- await updateStatus();
294
- }
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);
295
557
  };
296
558
  }
297
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
+
298
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
+
299
588
  initTheme(state);
300
589
  initCharts(state);
301
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
+
302
608
  void updateStatus()
303
609
  .then(() => {
304
- startRefreshTimer();
610
+ if (autoRefreshToggle?.checked) {
611
+ startRefreshTimer();
612
+ }
305
613
  })
306
614
  .catch(console.error);