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.
- package/README.md +29 -9
- package/package.json +17 -7
- package/src/admin/client/charts.ts +598 -0
- package/src/admin/client/main.js +3 -0
- package/src/admin/client/tailwind.css +41 -0
- package/src/admin/favicon.svg +3 -0
- package/src/admin/index.html +529 -0
- package/src/admin/js/api.ts +170 -0
- package/src/admin/js/app.ts +614 -0
- package/src/admin/js/eslint.config.js +74 -0
- package/src/admin/js/schemas.ts +215 -0
- package/src/admin/js/templates/config.ts +147 -0
- package/src/admin/js/templates/errorRow.ts +21 -0
- package/src/admin/js/templates/index.ts +4 -0
- package/src/admin/js/templates/poolCard.ts +61 -0
- package/src/admin/js/templates/traceRow.ts +24 -0
- package/src/admin/js/tsconfig.json +24 -0
- package/src/admin/js/types.ts +336 -0
- package/src/admin/js/ui/components.ts +44 -0
- package/src/admin/js/ui/table.ts +173 -0
- package/src/admin/js/ui/theme.ts +93 -0
- package/src/admin/js/ui/views.ts +427 -0
- package/src/admin/js/util/format.ts +46 -0
- package/src/admin/js/util/metrics.ts +24 -0
- package/src/admin/lib/chart.bundle.css +1 -0
- package/src/admin/lib/chart.bundle.js +146 -0
- package/src/admin/style.css +1437 -0
- package/src/bin/load-test.js +202 -0
- package/src/handler/handlerAdmin.js +293 -0
- package/src/handler/plsql/cgi.js +1 -1
- package/src/handler/plsql/errorPage.js +31 -6
- package/src/handler/plsql/handlerPlSql.js +32 -6
- package/src/handler/plsql/owaPageStream.js +93 -0
- package/src/handler/plsql/parsePage.js +7 -3
- package/src/handler/plsql/procedure.js +221 -105
- package/src/handler/plsql/procedureNamed.js +34 -49
- package/src/handler/plsql/procedureSanitize.js +120 -45
- package/src/handler/plsql/procedureVariable.js +4 -2
- package/src/handler/plsql/request.js +8 -3
- package/src/handler/plsql/sendResponse.js +43 -5
- package/src/index.js +0 -1
- package/src/server/adminContext.js +23 -0
- package/src/server/config.js +1 -0
- package/src/server/server.js +130 -9
- package/src/types.js +7 -1
- package/src/util/cache.js +123 -0
- package/src/util/jsonLogger.js +47 -0
- package/src/util/shutdown.js +6 -2
- package/src/util/statsManager.js +368 -0
- package/src/util/trace.js +7 -6
- package/src/util/traceManager.js +68 -0
- package/src/version.js +1 -1
- package/types/admin/js/schemas.d.ts +384 -0
- package/types/admin/js/types.d.ts +312 -0
- package/types/handler/handlerAdmin.d.ts +79 -0
- package/types/handler/plsql/errorPage.d.ts +1 -0
- package/types/handler/plsql/handlerPlSql.d.ts +6 -1
- package/types/handler/plsql/owaPageStream.d.ts +28 -0
- package/types/handler/plsql/procedure.d.ts +4 -1
- package/types/handler/plsql/procedureNamed.d.ts +2 -5
- package/types/handler/plsql/procedureSanitize.d.ts +2 -5
- package/types/handler/plsql/procedureVariable.d.ts +1 -1
- package/types/handler/plsql/request.d.ts +3 -1
- package/types/handler/plsql/sendResponse.d.ts +1 -1
- package/types/index.d.ts +0 -1
- package/types/server/adminContext.d.ts +17 -0
- package/types/server/server.d.ts +5 -0
- package/types/types.d.ts +19 -1
- package/types/util/cache.d.ts +69 -0
- package/types/util/jsonLogger.d.ts +45 -0
- package/types/util/statsManager.d.ts +395 -0
- package/types/util/traceManager.d.ts +35 -0
- package/src/handler/handlerMetrics.js +0 -68
- 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
|
+
]);
|