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
@@ -4,25 +4,17 @@ import {z} from 'zod';
4
4
  * Cache statistics schema.
5
5
  */
6
6
  export const cacheStatsSchema = z.object({
7
+ size: z.number(),
7
8
  hits: z.number(),
8
9
  misses: z.number(),
9
10
  });
10
11
 
11
12
  /**
12
- * Cache information schema.
13
- */
14
- export const cacheInfoSchema = z.object({
15
- size: z.number(),
16
- stats: cacheStatsSchema,
17
- });
18
-
19
- /**
20
- * Cache data schema for a connection pool.
13
+ * Pool cache snapshot schema.
21
14
  */
22
- export const cacheDataSchema = z.object({
23
- poolName: z.string(),
24
- procedureNameCache: cacheInfoSchema,
25
- argumentCache: cacheInfoSchema,
15
+ export const poolCacheSnapshotSchema = z.object({
16
+ procedureName: cacheStatsSchema,
17
+ argument: cacheStatsSchema,
26
18
  });
27
19
 
28
20
  /**
@@ -44,6 +36,7 @@ export const poolInfoSchema = z.object({
44
36
  connectionsInUse: z.number(),
45
37
  connectionsOpen: z.number(),
46
38
  stats: poolStatsSchema.nullable(),
39
+ cache: poolCacheSnapshotSchema.optional(),
47
40
  });
48
41
 
49
42
  /**
@@ -53,6 +46,38 @@ export const metricsSchema = z.object({
53
46
  requestCount: z.number(),
54
47
  errorCount: z.number(),
55
48
  avgResponseTime: z.number(),
49
+ minResponseTime: z.number(),
50
+ maxResponseTime: z.number(),
51
+ maxRequestsPerSecond: z.number(),
52
+ });
53
+
54
+ /**
55
+ * Historical bucket schema.
56
+ */
57
+ export const bucketSchema = z.object({
58
+ timestamp: z.number(),
59
+ requests: z.number(),
60
+ errors: z.number(),
61
+ durationMin: z.number(),
62
+ durationMax: z.number(),
63
+ durationAvg: z.number(),
64
+ durationP95: z.number(),
65
+ durationP99: z.number(),
66
+ system: z.object({
67
+ cpu: z.number(),
68
+ heapUsed: z.number(),
69
+ heapTotal: z.number(),
70
+ rss: z.number(),
71
+ external: z.number(),
72
+ }),
73
+ pools: z.array(
74
+ z.object({
75
+ name: z.string(),
76
+ connectionsInUse: z.number(),
77
+ connectionsOpen: z.number(),
78
+ cache: poolCacheSnapshotSchema.optional(),
79
+ }),
80
+ ),
56
81
  });
57
82
 
58
83
  /**
@@ -97,15 +122,24 @@ export const systemInfoSchema = z.object({
97
122
  nodeVersion: z.string(),
98
123
  platform: z.string(),
99
124
  arch: z.string(),
125
+ cpuCores: z.number().optional(),
100
126
  memory: z.object({
101
127
  rss: z.number(),
102
128
  heapTotal: z.number(),
103
129
  heapUsed: z.number(),
104
130
  external: z.number(),
131
+ totalMemory: z.number().optional(),
132
+ rssMax: z.number().optional(),
133
+ heapTotalMax: z.number().optional(),
134
+ heapUsedMax: z.number().optional(),
135
+ externalMax: z.number().optional(),
105
136
  }),
106
137
  cpu: z.object({
107
138
  user: z.number(),
108
139
  system: z.number(),
140
+ max: z.number().optional(),
141
+ userMax: z.number().optional(),
142
+ systemMax: z.number().optional(),
109
143
  }),
110
144
  });
111
145
 
@@ -117,7 +151,9 @@ export const statusSchema = z.object({
117
151
  status: z.enum(['running', 'paused', 'stopped']),
118
152
  uptime: z.number(),
119
153
  startTime: z.string(),
154
+ intervalMs: z.number().optional(),
120
155
  metrics: metricsSchema,
156
+ history: z.array(bucketSchema).optional(),
121
157
  pools: z.array(poolInfoSchema),
122
158
  system: systemInfoSchema,
123
159
  config: serverConfigSchema.partial(),
@@ -144,10 +180,36 @@ export const errorLogSchema = z.object({
144
180
 
145
181
  export const accessLogResponseSchema = z.union([z.array(z.string()), z.object({message: z.string()})]);
146
182
 
183
+ /**
184
+ * Trace entry schema.
185
+ */
186
+ export const traceEntrySchema = z.object({
187
+ id: z.string(),
188
+ timestamp: z.string(),
189
+ source: z.string(),
190
+ url: z.string(),
191
+ method: z.string(),
192
+ status: z.string(),
193
+ duration: z.number(),
194
+ procedure: z.string().optional(),
195
+ parameters: z.record(z.string(), z.any()).optional(),
196
+ uploads: z.array(z.any()).optional(),
197
+ downloads: z
198
+ .object({
199
+ fileType: z.string(),
200
+ fileSize: z.number(),
201
+ })
202
+ .optional(),
203
+ html: z.string().optional(),
204
+ cookies: z.record(z.string(), z.string()).optional(),
205
+ headers: z.record(z.string(), z.string()).optional(),
206
+ cgi: z.record(z.string(), z.string()).optional(),
207
+ error: z.string().optional(),
208
+ });
209
+
147
210
  /**
148
211
  * API response type helpers.
149
212
  */
150
213
  export type StatusResponse = z.infer<typeof statusSchema>;
151
- export type CacheDataResponse = z.infer<typeof cacheDataSchema>;
152
214
  export type ErrorLogResponse = z.infer<typeof errorLogSchema>;
153
215
  export type AccessLogResponse = z.infer<typeof accessLogResponseSchema>;
@@ -6,12 +6,13 @@ import type {ServerConfig, RouteConfig} from '../types.js';
6
6
  * @param value - The value to display.
7
7
  * @param isMono - Whether to use monospaced font.
8
8
  * @param colorClass - The CSS class for coloring the value.
9
+ * @param title - Optional tooltip explanation.
9
10
  * @returns HTML string.
10
11
  */
11
- function renderStatRow(label: string, value: string | number, isMono = true, colorClass = 'text-accent'): string {
12
+ function renderStatRow(label: string, value: string | number, isMono = true, colorClass = 'text-accent', title = ''): string {
12
13
  const valClass = `${isMono ? 'font-mono' : ''} ${colorClass} font-bold break-all`.trim();
13
14
  return `
14
- <div class="stat-row text-sm">
15
+ <div class="stat-row text-sm" ${title ? `title="${title}"` : ''}>
15
16
  <span>${label}</span>
16
17
  <span class="${valClass}">${value}</span>
17
18
  </div>
@@ -109,19 +110,19 @@ export function renderConfig(config: Partial<ServerConfig>): string {
109
110
  html += '<div class="card mb-8">';
110
111
  html += '<div class="card-header"><span class="material-symbols-rounded">dns</span><h3>Server Settings</h3></div>';
111
112
  if (typeof config.port === 'number') {
112
- html += renderStatRow('Port', config.port);
113
+ html += renderStatRow('Port', config.port, true, 'text-accent', 'The TCP port the server is listening on');
113
114
  }
114
- html += renderStatRow('Admin Route', config.adminRoute ?? '/admin');
115
- html += renderStatRow('Admin User', config.adminUser ?? '(Not authenticated)');
116
- html += renderStatRow('Admin Password', config.adminPassword ?? '(None)');
117
- html += renderStatRow('Logger Filename', config.loggerFilename ?? '(Logging disabled)');
115
+ html += renderStatRow('Admin Route', config.adminRoute ?? '/admin', true, 'text-accent', 'The URL path for the administration console');
116
+ html += renderStatRow('Admin User', config.adminUser ?? '(Not authenticated)', true, 'text-accent', 'The username required for admin access');
117
+ html += renderStatRow('Admin Password', config.adminPassword ?? '(None)', true, 'text-accent', 'The password required for admin access');
118
+ html += renderStatRow('Logger Filename', config.loggerFilename ?? '(Logging disabled)', true, 'text-accent', 'The path to the server log file');
118
119
 
119
120
  if (typeof config.uploadFileSizeLimit === 'number') {
120
121
  const limit = config.uploadFileSizeLimit;
121
122
  const mb = (limit / (1024 * 1024)).toFixed(2);
122
- html += renderStatRow('Upload Size Limit', `${mb} MB (${limit.toLocaleString()} bytes)`);
123
+ html += renderStatRow('Upload Size Limit', `${mb} MB (${limit.toLocaleString()} bytes)`, true, 'text-accent', 'Maximum allowed size for file uploads');
123
124
  } else {
124
- html += renderStatRow('Upload Size Limit', 'No limit');
125
+ html += renderStatRow('Upload Size Limit', 'No limit', false, 'text-accent', 'Maximum allowed size for file uploads');
125
126
  }
126
127
  html += '</div>';
127
128
 
@@ -8,11 +8,14 @@ import type {ErrorLogResponse} from '../schemas.js';
8
8
  */
9
9
  export function errorRow(l: ErrorLogResponse): string {
10
10
  return `
11
- <tr>
12
- <td>${new Date(l.timestamp).toLocaleString()}</td>
13
- <td><code>${l.req?.method ?? '-'}</code></td>
14
- <td>${l.req?.url ?? '-'}</td>
15
- <td title="${l.details?.fullMessage ?? ''}">${l.message}</td>
11
+ <tr title="Click to see full error details">
12
+ <td title="Timestamp: ${new Date(l.timestamp).toLocaleString()}">${new Date(l.timestamp).toLocaleString()}</td>
13
+ <td title="Method: ${l.req?.method ?? '-'}"><code>${l.req?.method ?? '-'}</code></td>
14
+ <td title="URL: ${l.req?.url ?? '-'}">${l.req?.url ?? '-'}</td>
15
+ <td title="Click for details: ${l.details?.fullMessage ?? ''}" class="break-all">${l.message}</td>
16
+ <td style="width: 40px; text-align: center; color: var(--accent); opacity: 0.7">
17
+ <span class="material-symbols-rounded">chevron_right</span>
18
+ </td>
16
19
  </tr>
17
20
  `;
18
21
  }
@@ -1,3 +1,4 @@
1
1
  export {errorRow} from './errorRow.js';
2
+ export {traceRow} from './traceRow.js';
2
3
  export {poolCard} from './poolCard.js';
3
4
  export {renderConfig} from './config.js';
@@ -15,12 +15,12 @@ export function poolCard(p: PoolInfo): string {
15
15
  const stats = p.stats ?? undefined;
16
16
 
17
17
  return `
18
- <div class="card card-clickable transition-all duration-200">
18
+ <div class="card card-clickable transition-all duration-200" title="Connection pool status for ${p.name}">
19
19
  <div class="card-header">
20
20
  <span class="material-symbols-rounded">database</span>
21
21
  <h3 class="text-lg font-bold text-bright">${p.name}</h3>
22
22
  </div>
23
- <div class="mb-6">
23
+ <div class="mb-6" title="Utilization: ${utilization}% of open connections are currently in use">
24
24
  <div class="flex justify-between items-baseline mb-2">
25
25
  <span class="text-3xl font-bold text-bright">${utilization}%</span>
26
26
  <span class="text-main font-mono">${p.connectionsInUse} / ${p.connectionsOpen}</span>
@@ -31,11 +31,11 @@ export function poolCard(p: PoolInfo): string {
31
31
  <small class="description mt-2 block opacity-75">Pool Utilization (Active / Open)</small>
32
32
  </div>
33
33
 
34
- <div class="stat-row text-sm">
34
+ <div class="stat-row text-sm" title="Total number of connections currently open in the pool">
35
35
  <span class="text-xs uppercase font-bold opacity-75">Connections Open</span>
36
36
  <span class="font-mono text-accent font-bold">${p.connectionsOpen}</span>
37
37
  </div>
38
- <div class="stat-row text-sm">
38
+ <div class="stat-row text-sm" title="Number of connections currently active and handling requests">
39
39
  <span class="text-xs uppercase font-bold opacity-75">Connections In Use</span>
40
40
  <span class="font-mono text-accent font-bold">${p.connectionsInUse}</span>
41
41
  </div>
@@ -44,11 +44,11 @@ export function poolCard(p: PoolInfo): string {
44
44
  stats
45
45
  ? `
46
46
  <div class="mt-4 pt-4 border-t">
47
- <div class="stat-row text-sm">
47
+ <div class="stat-row text-sm" title="Cumulative number of requests handled by this pool since server start">
48
48
  <span>Total Requests</span>
49
49
  <span class="font-mono text-accent font-bold">${stats.totalRequests.toLocaleString()}</span>
50
50
  </div>
51
- <div class="stat-row text-sm">
51
+ <div class="stat-row text-sm" title="Number of requests that timed out waiting for a connection">
52
52
  <span>Total Timeouts</span>
53
53
  <span class="font-mono ${stats.totalTimeouts > 0 ? 'text-danger' : 'text-accent'} font-bold">${stats.totalTimeouts.toLocaleString()}</span>
54
54
  </div>
@@ -0,0 +1,24 @@
1
+ import type {TraceEntry} from '../types.js';
2
+ import {formatMs} from '../util/format.js';
3
+
4
+ /**
5
+ * Render a single trace row for the trace table.
6
+ *
7
+ * @param trace - The trace log entry.
8
+ * @returns HTML string.
9
+ */
10
+ export function traceRow(trace: TraceEntry): string {
11
+ const time = new Date(trace.timestamp).toLocaleTimeString();
12
+ const statusClass = trace.status === 'success' ? 'text-success' : 'text-danger';
13
+
14
+ return `
15
+ <tr class="trace-table-row cursor-pointer hover:bg-white/5 transition-colors border-b border-white/5" data-id="${trace.id}">
16
+ <td class="px-4 py-2 font-mono text-xs text-dim">${time}</td>
17
+ <td class="px-4 py-2 text-dim text-xs truncate max-w-[150px]" title="${trace.source}">${trace.source}</td>
18
+ <td class="px-4 py-2 text-bright font-mono text-xs truncate max-w-[300px]" title="${trace.url}">${trace.url}</td>
19
+ <td class="px-4 py-2 font-bold ${statusClass} text-xs uppercase">${trace.status}</td>
20
+ <td class="px-4 py-2 text-right font-mono text-xs text-dim">${formatMs(trace.duration)}</td>
21
+ <td class="px-4 py-2 text-dim text-xs truncate max-w-[200px]" title="${trace.procedure ?? ''}">${trace.procedure ?? '-'}</td>
22
+ </tr>
23
+ `;
24
+ }
@@ -51,6 +51,9 @@ export type Metrics = {
51
51
  requestCount: number;
52
52
  errorCount: number;
53
53
  avgResponseTime: number;
54
+ minResponseTime: number;
55
+ maxResponseTime: number;
56
+ maxRequestsPerSecond: number;
54
57
  };
55
58
 
56
59
  /**
@@ -96,11 +99,38 @@ export type Status = {
96
99
  status: 'running' | 'paused' | 'stopped';
97
100
  uptime: number;
98
101
  startTime: string;
102
+ intervalMs?: number;
99
103
  metrics: Metrics;
100
104
  pools: PoolInfo[];
101
105
  config: Partial<ServerConfig>;
102
106
  };
103
107
 
108
+ /**
109
+ * Resident Set Size - total memory used by the process
110
+ */
111
+ export type HistoryBucket = {
112
+ timestamp: number;
113
+ requests: number;
114
+ errors: number;
115
+ durationMin: number;
116
+ durationMax: number;
117
+ durationAvg: number;
118
+ durationP95: number;
119
+ durationP99: number;
120
+ system: {
121
+ cpu: number;
122
+ heapUsed: number;
123
+ heapTotal: number;
124
+ rss: number;
125
+ external: number;
126
+ };
127
+ pools: {
128
+ name: string;
129
+ connectionsInUse: number;
130
+ connectionsOpen: number;
131
+ }[];
132
+ };
133
+
104
134
  /**
105
135
  * Historical data for charts.
106
136
  */
@@ -108,6 +138,8 @@ export type HistoryData = {
108
138
  labels: string[];
109
139
  requests: number[];
110
140
  avgResponseTimes: number[];
141
+ p95ResponseTimes?: number[];
142
+ p99ResponseTimes?: number[];
111
143
  poolUsage: Record<string, number[]>;
112
144
  };
113
145
 
@@ -115,45 +147,85 @@ export type HistoryData = {
115
147
  * Chart grid options.
116
148
  */
117
149
  export type ChartGridOptions = {
118
- color: string;
150
+ color?: string;
151
+ display?: boolean;
152
+ drawBorder?: boolean;
153
+ drawOnChartArea?: boolean;
119
154
  };
120
155
 
121
156
  /**
122
157
  * Chart ticks options.
123
158
  */
124
159
  export type ChartTicksOptions = {
125
- color: string;
160
+ color?: string;
126
161
  display?: boolean;
162
+ stepSize?: number;
163
+ maxRotation?: number;
164
+ autoSkip?: boolean;
165
+ maxTicksLimit?: number;
166
+ callback?: (value: number | string) => string;
127
167
  };
128
168
 
129
169
  /**
130
170
  * Chart scale options.
131
171
  */
132
172
  export type ChartScaleOptions = {
173
+ display?: boolean;
133
174
  grid?: ChartGridOptions;
134
175
  ticks?: ChartTicksOptions;
176
+ border?: {
177
+ display?: boolean;
178
+ color?: string;
179
+ };
135
180
  title?: {
136
181
  display: boolean;
137
182
  text: string;
138
183
  color: string;
139
184
  };
185
+ type?: string;
186
+ position?: string;
187
+ beginAtZero?: boolean;
188
+ max?: number;
140
189
  };
141
190
 
142
191
  /**
143
192
  * Chart options.
144
193
  */
145
194
  export type ChartOptions = {
195
+ responsive?: boolean;
196
+ maintainAspectRatio?: boolean;
146
197
  scales?: {
147
198
  x?: ChartScaleOptions;
148
199
  y?: ChartScaleOptions;
149
200
  y1?: ChartScaleOptions;
150
201
  };
202
+ layout?: {
203
+ padding?: number;
204
+ };
151
205
  plugins?: {
152
206
  legend?: {
207
+ display?: boolean;
153
208
  labels?: {
154
- color: string;
209
+ color?: string;
155
210
  };
156
211
  };
212
+ tooltip?: {
213
+ enabled?: boolean;
214
+ position?: string;
215
+ intersect?: boolean;
216
+ callbacks?: Record<string, unknown>;
217
+ };
218
+ };
219
+ elements?: {
220
+ point?: {
221
+ radius?: number;
222
+ };
223
+ line?: {
224
+ borderWidth?: number;
225
+ };
226
+ bar?: {
227
+ borderWidth?: number;
228
+ };
157
229
  };
158
230
  };
159
231
 
@@ -165,8 +237,13 @@ export type ChartDataset = {
165
237
  data: number[];
166
238
  borderColor: string;
167
239
  backgroundColor: string | undefined;
168
- fill: boolean | undefined;
169
- tension: number | undefined;
240
+ fill?: boolean | undefined;
241
+ tension?: number | undefined;
242
+ barThickness?: number;
243
+ categoryPercentage?: number;
244
+ barPercentage?: number;
245
+ borderWidth?: number;
246
+ yAxisID?: string;
170
247
  };
171
248
 
172
249
  /**
@@ -187,25 +264,48 @@ export type ChartInstance = {
187
264
  };
188
265
 
189
266
  /**
190
- * Error log entry.
267
+ * Trace entry.
191
268
  */
192
- export type ErrorLog = {
269
+ export type TraceEntry = {
270
+ id: string;
193
271
  timestamp: string;
194
- req:
195
- | {
196
- method: string;
197
- url: string;
198
- }
199
- | undefined;
200
- message: string;
201
- details:
272
+ source: string;
273
+ url: string;
274
+ method: string;
275
+ status: string;
276
+ duration: number;
277
+ procedure?: string | undefined;
278
+ parameters?: Record<string, unknown> | undefined;
279
+ uploads?: {originalname: string; mimetype: string; size: number}[] | undefined;
280
+ downloads?:
202
281
  | {
203
- fullMessage: string;
282
+ fileType: string;
283
+ fileSize: number;
204
284
  }
205
285
  | undefined;
286
+ html?: string | undefined;
287
+ cookies?: Record<string, string> | undefined;
288
+ headers?: Record<string, string> | undefined;
289
+ cgi?: Record<string, string> | undefined;
290
+ error?: string | undefined;
206
291
  };
207
292
 
208
- import type {StatusResponse} from './schemas.js';
293
+ import type {StatusResponse, ErrorLogResponse, AccessLogResponse} from './schemas.js';
294
+
295
+ // Re-export types from schemas
296
+ export type {StatusResponse, ErrorLogResponse, AccessLogResponse};
297
+
298
+ /**
299
+ * System metrics for tracking min/max.
300
+ */
301
+ export type SystemMetrics = {
302
+ heapUsed: number;
303
+ heapTotal: number;
304
+ rss: number;
305
+ external: number;
306
+ cpuUser: number;
307
+ cpuSystem: number;
308
+ };
209
309
 
210
310
  /**
211
311
  * Application state.
@@ -217,7 +317,20 @@ export type State = {
217
317
  lastRequestCount: number;
218
318
  lastErrorCount: number;
219
319
  lastUpdateTime: number;
220
- refreshTimer: ReturnType<typeof setInterval> | null;
221
- history: HistoryData;
320
+ lastBucketTimestamp: number;
321
+ nextRefreshTimeout: ReturnType<typeof setTimeout> | null;
322
+ nextRefreshTime: number;
323
+ countdownInterval: ReturnType<typeof setInterval> | null;
324
+ history: HistoryData & {
325
+ cpuUsage: number[];
326
+ memoryUsage: number[];
327
+ };
222
328
  charts: Record<string, ChartInstance>;
329
+ metricsMin: Partial<SystemMetrics>;
330
+ metricsMax: Partial<SystemMetrics>;
223
331
  };
332
+
333
+ declare global {
334
+ /** Injected by Vite during build */
335
+ const __BUILD_TIME__: string;
336
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Enhances a button with loading state logic.
3
+ *
4
+ * @param btn - The button element to enhance.
5
+ * @param action - The async function to execute when the button is clicked.
6
+ * @returns The result of the action.
7
+ */
8
+ export async function withLoading<T>(btn: HTMLButtonElement, action: () => Promise<T>): Promise<T> {
9
+ const iconSpan = btn.querySelector('.material-symbols-rounded');
10
+ const originalIcon = iconSpan?.textContent;
11
+
12
+ btn.disabled = true;
13
+ btn.classList.add('loading');
14
+ if (iconSpan) {
15
+ iconSpan.textContent = 'sync';
16
+ iconSpan.classList.add('animate-spin');
17
+ }
18
+
19
+ try {
20
+ return await action();
21
+ } finally {
22
+ btn.disabled = false;
23
+ btn.classList.remove('loading');
24
+ if (iconSpan) {
25
+ iconSpan.textContent = originalIcon ?? '';
26
+ iconSpan.classList.remove('animate-spin');
27
+ }
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Binds an async action to a button with standardized loading feedback.
33
+ *
34
+ * @param id - The ID of the button element.
35
+ * @param action - The async action to perform.
36
+ * @returns The button element or null if not found.
37
+ */
38
+ export function bindLoadingButton(id: string, action: () => Promise<void>): HTMLButtonElement | null {
39
+ const btn = document.getElementById(id) as HTMLButtonElement | null;
40
+ if (btn) {
41
+ btn.onclick = () => void withLoading(btn, action);
42
+ }
43
+ return btn;
44
+ }