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,598 @@
1
+ import {Chart, registerables} from 'chart.js';
2
+ import type {State, ChartInstance, HistoryBucket} from '../js/types.js';
3
+ import type {StatusResponse} from '../js/schemas.js';
4
+
5
+ // Register Chart.js components
6
+ Chart.register(...registerables);
7
+
8
+ /**
9
+ * Cache pie chart instances.
10
+ */
11
+ const cachePieInstances = new Map<HTMLCanvasElement, Chart>();
12
+
13
+ type PoolInfo = StatusResponse['pools'][number];
14
+
15
+ /**
16
+ * Chart colors based on theme mode.
17
+ */
18
+ type ChartColors = {
19
+ gridColor: string;
20
+ textColor: string;
21
+ };
22
+
23
+ /**
24
+ * Get chart colors for the current theme.
25
+ * @param isDark - Whether dark mode is active.
26
+ * @returns Chart color configuration.
27
+ */
28
+ function getChartColors(isDark: boolean): ChartColors {
29
+ return {
30
+ gridColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
31
+ textColor: isDark ? '#94a3b8' : '#475569',
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Initialize charts.
37
+ * @param state - Application state.
38
+ */
39
+ export function initCharts(state: State): void {
40
+ const isDark = document.body.classList.contains('dark');
41
+ const colors = getChartColors(isDark);
42
+
43
+ const memoryEl = document.getElementById('memory-chart') as HTMLCanvasElement | null;
44
+ if (memoryEl) {
45
+ const chartData = {
46
+ labels: [],
47
+ datasets: [
48
+ {
49
+ label: 'Memory Usage (%)',
50
+ data: [],
51
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
52
+ borderColor: '#10b981',
53
+ borderWidth: 2,
54
+ fill: true,
55
+ tension: 0.4,
56
+ },
57
+ ],
58
+ };
59
+
60
+ const chartOptions = {
61
+ responsive: true,
62
+ maintainAspectRatio: false,
63
+ scales: {
64
+ x: {
65
+ grid: {color: colors.gridColor},
66
+ ticks: {
67
+ display: false,
68
+ color: colors.textColor,
69
+ maxRotation: 0,
70
+ autoSkip: true,
71
+ maxTicksLimit: 10,
72
+ },
73
+ title: {
74
+ display: false,
75
+ text: 'Time',
76
+ color: colors.textColor,
77
+ },
78
+ },
79
+ y: {
80
+ display: true,
81
+ beginAtZero: true,
82
+ max: 100,
83
+ grid: {
84
+ color: colors.gridColor,
85
+ },
86
+ border: {
87
+ display: true,
88
+ color: colors.gridColor,
89
+ },
90
+ ticks: {
91
+ display: true,
92
+ color: colors.textColor,
93
+ stepSize: 25,
94
+ callback: function (value: number | string) {
95
+ return String(value) + '%';
96
+ },
97
+ },
98
+ title: {
99
+ display: false,
100
+ text: 'Memory %',
101
+ color: colors.textColor,
102
+ },
103
+ },
104
+ },
105
+ layout: {
106
+ padding: 0,
107
+ },
108
+ plugins: {
109
+ legend: {display: false},
110
+ tooltip: {
111
+ enabled: true,
112
+ position: 'nearest' as const,
113
+ intersect: false,
114
+ },
115
+ },
116
+ };
117
+
118
+ const chart = new Chart(memoryEl, {
119
+ type: 'line',
120
+ data: chartData,
121
+ options: chartOptions,
122
+ });
123
+
124
+ state.charts.memory = {
125
+ data: chartData,
126
+ options: chartOptions,
127
+ update: () => chart.update(),
128
+ } as ChartInstance;
129
+ }
130
+
131
+ const cpuEl = document.getElementById('cpu-chart') as HTMLCanvasElement | null;
132
+ if (cpuEl) {
133
+ const chartData = {
134
+ labels: [],
135
+ datasets: [
136
+ {
137
+ label: 'CPU Usage (%)',
138
+ data: [],
139
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
140
+ borderColor: '#10b981',
141
+ borderWidth: 2,
142
+ fill: true,
143
+ tension: 0.4,
144
+ },
145
+ ],
146
+ };
147
+
148
+ const chartOptions = {
149
+ responsive: true,
150
+ maintainAspectRatio: false,
151
+ scales: {
152
+ x: {
153
+ grid: {color: colors.gridColor},
154
+ ticks: {
155
+ display: false,
156
+ color: colors.textColor,
157
+ maxRotation: 0,
158
+ autoSkip: true,
159
+ maxTicksLimit: 10,
160
+ },
161
+ title: {
162
+ display: false,
163
+ text: 'Time',
164
+ color: colors.textColor,
165
+ },
166
+ },
167
+ y: {
168
+ display: true,
169
+ beginAtZero: true,
170
+ max: 100,
171
+ grid: {
172
+ color: colors.gridColor,
173
+ },
174
+ border: {
175
+ display: true,
176
+ color: colors.gridColor,
177
+ },
178
+ ticks: {
179
+ display: true,
180
+ color: colors.textColor,
181
+ stepSize: 25,
182
+ callback: function (value: number | string) {
183
+ return String(value) + '%';
184
+ },
185
+ },
186
+ title: {
187
+ display: false,
188
+ text: 'Cpu %',
189
+ color: colors.textColor,
190
+ },
191
+ },
192
+ },
193
+ layout: {
194
+ padding: 0,
195
+ },
196
+ plugins: {
197
+ legend: {display: false},
198
+ tooltip: {
199
+ enabled: true,
200
+ position: 'nearest' as const,
201
+ intersect: false,
202
+ },
203
+ },
204
+ };
205
+
206
+ const chart = new Chart(cpuEl, {
207
+ type: 'line',
208
+ data: chartData,
209
+ options: chartOptions,
210
+ });
211
+
212
+ state.charts.cpu = {
213
+ data: chartData,
214
+ options: chartOptions,
215
+ update: () => chart.update(),
216
+ } as ChartInstance;
217
+ }
218
+
219
+ const trafficEl = document.getElementById('traffic-chart') as HTMLCanvasElement | null;
220
+ if (trafficEl) {
221
+ const chartData = {
222
+ labels: [],
223
+ datasets: [
224
+ {
225
+ label: 'Requests/s',
226
+ data: [],
227
+ borderColor: '#3b82f6',
228
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
229
+ fill: true,
230
+ tension: 0.4,
231
+ yAxisID: 'y',
232
+ },
233
+ {
234
+ label: 'Avg Response Time (ms)',
235
+ data: [],
236
+ borderColor: '#10b981',
237
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
238
+ fill: true,
239
+ tension: 0.4,
240
+ yAxisID: 'y1',
241
+ },
242
+ ],
243
+ };
244
+
245
+ const chartOptions = {
246
+ responsive: true,
247
+ maintainAspectRatio: false,
248
+ scales: {
249
+ x: {
250
+ grid: {color: colors.gridColor},
251
+ ticks: {
252
+ display: false,
253
+ color: colors.textColor,
254
+ maxRotation: 0,
255
+ autoSkip: true,
256
+ maxTicksLimit: 10,
257
+ },
258
+ title: {
259
+ display: true,
260
+ text: 'Time',
261
+ color: colors.textColor,
262
+ },
263
+ },
264
+ y: {
265
+ type: 'linear' as const,
266
+ display: true,
267
+ position: 'left' as const,
268
+ grid: {color: colors.gridColor},
269
+ ticks: {color: colors.textColor},
270
+ beginAtZero: true,
271
+ title: {
272
+ display: true,
273
+ text: 'Requests/s',
274
+ color: colors.textColor,
275
+ },
276
+ },
277
+ y1: {
278
+ type: 'linear' as const,
279
+ display: true,
280
+ position: 'right' as const,
281
+ grid: {drawOnChartArea: false, color: colors.gridColor},
282
+ ticks: {color: '#10b981'},
283
+ beginAtZero: true,
284
+ title: {
285
+ display: true,
286
+ text: 'Avg Response Time (ms)',
287
+ color: '#10b981',
288
+ },
289
+ },
290
+ },
291
+ plugins: {
292
+ legend: {
293
+ labels: {color: colors.textColor},
294
+ },
295
+ },
296
+ };
297
+
298
+ const chart = new Chart(trafficEl, {
299
+ type: 'line',
300
+ data: chartData,
301
+ options: chartOptions,
302
+ });
303
+
304
+ state.charts.traffic = {
305
+ data: chartData,
306
+ options: chartOptions,
307
+ update: () => chart.update(),
308
+ } as ChartInstance;
309
+ }
310
+
311
+ const poolEl = document.getElementById('pool-chart') as HTMLCanvasElement | null;
312
+ if (poolEl) {
313
+ const chartData = {
314
+ labels: [],
315
+ datasets: [],
316
+ };
317
+
318
+ const chartOptions = {
319
+ responsive: true,
320
+ maintainAspectRatio: false,
321
+ scales: {
322
+ x: {
323
+ grid: {color: colors.gridColor},
324
+ ticks: {
325
+ display: false,
326
+ color: colors.textColor,
327
+ maxRotation: 0,
328
+ autoSkip: true,
329
+ maxTicksLimit: 10,
330
+ },
331
+ title: {
332
+ display: true,
333
+ text: 'Time',
334
+ color: colors.textColor,
335
+ },
336
+ },
337
+ y: {
338
+ grid: {color: colors.gridColor},
339
+ ticks: {color: colors.textColor},
340
+ beginAtZero: true,
341
+ title: {
342
+ display: true,
343
+ text: 'Connections',
344
+ color: colors.textColor,
345
+ },
346
+ },
347
+ },
348
+ plugins: {
349
+ legend: {
350
+ labels: {color: colors.textColor},
351
+ },
352
+ },
353
+ };
354
+
355
+ const chart = new Chart(poolEl, {
356
+ type: 'line',
357
+ data: chartData,
358
+ options: chartOptions,
359
+ });
360
+
361
+ state.charts.pool = {
362
+ data: chartData,
363
+ options: chartOptions,
364
+ update: () => chart.update(),
365
+ } as ChartInstance;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Hydrate charts and state from server history.
371
+ * @param state - Application state.
372
+ * @param history - Server history buffer.
373
+ */
374
+ export function hydrateHistory(state: State, history: HistoryBucket[]): void {
375
+ state.history.labels = [];
376
+ state.history.requests = [];
377
+ state.history.avgResponseTimes = [];
378
+ state.history.p95ResponseTimes = [];
379
+ state.history.p99ResponseTimes = [];
380
+ state.history.cpuUsage = [];
381
+ state.history.memoryUsage = [];
382
+ state.history.poolUsage = {};
383
+
384
+ const maxPoints = state.maxHistoryPoints;
385
+ const recentHistory = history.slice(-maxPoints);
386
+ const intervalSec = (state.status.intervalMs ?? 5000) / 1000;
387
+
388
+ recentHistory.forEach((b) => {
389
+ const timeLabel = new Date(b.timestamp).toLocaleTimeString();
390
+ state.history.labels.push(timeLabel);
391
+ state.history.requests.push(b.requests / intervalSec); // Requests per second
392
+ state.history.avgResponseTimes.push(b.durationAvg);
393
+ state.history.p95ResponseTimes ??= [];
394
+ state.history.p95ResponseTimes.push(b.durationP95);
395
+ state.history.p99ResponseTimes ??= [];
396
+ state.history.p99ResponseTimes.push(b.durationP99);
397
+
398
+ // Resource usage percentage
399
+ const totalMem = state.status.system?.memory.totalMemory ?? 0;
400
+ state.history.cpuUsage.push(b.system.cpu);
401
+ state.history.memoryUsage.push(totalMem > 0 ? (b.system.rss / totalMem) * 100 : 0);
402
+
403
+ b.pools.forEach((p) => {
404
+ state.history.poolUsage[p.name] ??= [];
405
+ state.history.poolUsage[p.name]?.push(p.connectionsInUse);
406
+ });
407
+ });
408
+
409
+ const trafficChart = state.charts.traffic;
410
+ if (trafficChart) {
411
+ trafficChart.data.labels = state.history.labels;
412
+ const ds0 = trafficChart.data.datasets[0];
413
+ const ds1 = trafficChart.data.datasets[1];
414
+ if (ds0) ds0.data = state.history.requests;
415
+ if (ds1) ds1.data = state.history.avgResponseTimes;
416
+ trafficChart.update();
417
+ }
418
+
419
+ const poolChart = state.charts.pool;
420
+ if (poolChart) {
421
+ poolChart.data.labels = state.history.labels;
422
+ Object.entries(state.history.poolUsage).forEach(([name, usage], i) => {
423
+ let dataset = poolChart.data.datasets.find((ds) => ds.label === name);
424
+ if (!dataset) {
425
+ const colors = ['#10b981', '#f59e0b', '#3b82f6', '#8b5cf6', '#ec4899'];
426
+ const color = colors[i % colors.length] ?? '#3b82f6';
427
+ dataset = {
428
+ label: name,
429
+ data: usage,
430
+ borderColor: color,
431
+ backgroundColor: undefined,
432
+ fill: undefined,
433
+ tension: 0.4,
434
+ };
435
+ poolChart.data.datasets.push(dataset);
436
+ } else {
437
+ dataset.data = usage;
438
+ }
439
+ });
440
+ poolChart.update();
441
+ }
442
+
443
+ const memoryChart = state.charts.memory;
444
+ if (memoryChart) {
445
+ memoryChart.data.labels = state.history.labels;
446
+ if (memoryChart.data.datasets[0]) {
447
+ memoryChart.data.datasets[0].data = state.history.memoryUsage;
448
+ }
449
+ memoryChart.update();
450
+ }
451
+
452
+ const cpuChart = state.charts.cpu;
453
+ if (cpuChart) {
454
+ cpuChart.data.labels = state.history.labels;
455
+ if (cpuChart.data.datasets[0]) {
456
+ cpuChart.data.datasets[0].data = state.history.cpuUsage;
457
+ }
458
+ cpuChart.update();
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Update charts with new data.
464
+ * @param state - Application state.
465
+ * @param timeLabel - Time label for the data point.
466
+ * @param reqPerSec - Requests per second.
467
+ * @param avgResponseTime - Average response time in ms.
468
+ * @param pools - Pool information.
469
+ */
470
+ export function updateCharts(state: State, timeLabel: string, reqPerSec: number, avgResponseTime: number, pools: PoolInfo[]): void {
471
+ const maxPoints = state.maxHistoryPoints;
472
+
473
+ state.history.labels.push(timeLabel);
474
+ state.history.requests.push(reqPerSec);
475
+ state.history.avgResponseTimes.push(avgResponseTime);
476
+
477
+ // Resource usage percentage
478
+ const totalMem = state.status.system?.memory.totalMemory ?? 0;
479
+ const latestBucket = state.status.history?.[state.status.history.length - 1];
480
+ if (latestBucket) {
481
+ state.history.cpuUsage.push(latestBucket.system.cpu);
482
+ state.history.memoryUsage.push(totalMem > 0 ? (latestBucket.system.rss / totalMem) * 100 : 0);
483
+ } else {
484
+ state.history.cpuUsage.push(0);
485
+ state.history.memoryUsage.push(0);
486
+ }
487
+
488
+ if (state.history.labels.length > maxPoints) {
489
+ state.history.labels.shift();
490
+ state.history.requests.shift();
491
+ state.history.avgResponseTimes.shift();
492
+ state.history.cpuUsage.shift();
493
+ state.history.memoryUsage.shift();
494
+ }
495
+
496
+ const trafficChart = state.charts.traffic;
497
+ if (trafficChart?.data.datasets[0] && trafficChart.data.datasets[1]) {
498
+ trafficChart.data.labels = state.history.labels;
499
+ trafficChart.data.datasets[0].data = state.history.requests;
500
+ trafficChart.data.datasets[1].data = state.history.avgResponseTimes;
501
+ trafficChart.update();
502
+ }
503
+
504
+ const memoryChart = state.charts.memory;
505
+ if (memoryChart?.data.datasets[0]) {
506
+ memoryChart.data.labels = state.history.labels;
507
+ memoryChart.data.datasets[0].data = state.history.memoryUsage;
508
+ memoryChart.update();
509
+ }
510
+
511
+ const cpuChart = state.charts.cpu;
512
+ if (cpuChart?.data.datasets[0]) {
513
+ cpuChart.data.labels = state.history.labels;
514
+ cpuChart.data.datasets[0].data = state.history.cpuUsage;
515
+ cpuChart.update();
516
+ }
517
+
518
+ const poolChart = state.charts.pool;
519
+ if (poolChart) {
520
+ poolChart.data.labels = state.history.labels;
521
+ pools.forEach((p, i) => {
522
+ const poolName = p.name;
523
+ const history = state.history;
524
+ let usage = history.poolUsage[poolName];
525
+ if (!usage) {
526
+ usage = [];
527
+ history.poolUsage[poolName] = usage;
528
+ const colors = ['#10b981', '#f59e0b', '#3b82f6', '#8b5cf6', '#ec4899'];
529
+ const color = colors[i % colors.length] ?? '#3b82f6';
530
+ if (color) {
531
+ poolChart.data.datasets.push({
532
+ label: poolName,
533
+ data: usage,
534
+ borderColor: color,
535
+ backgroundColor: undefined,
536
+ fill: undefined,
537
+ tension: 0.4,
538
+ });
539
+ }
540
+ }
541
+ usage.push(p.connectionsInUse);
542
+ if (usage.length > maxPoints) {
543
+ usage.shift();
544
+ }
545
+ });
546
+ poolChart.update();
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Render a cache statistics pie chart.
552
+ *
553
+ * @param canvas - The canvas element.
554
+ */
555
+ export function renderCachePie(canvas: HTMLCanvasElement): void {
556
+ const hits = parseInt(canvas.dataset.hits ?? '0');
557
+ const misses = parseInt(canvas.dataset.misses ?? '0');
558
+
559
+ const existingChart = cachePieInstances.get(canvas);
560
+ if (existingChart?.data.datasets[0]) {
561
+ existingChart.data.datasets[0].data = [hits, misses];
562
+ existingChart.update();
563
+ return;
564
+ }
565
+
566
+ const chart = new Chart(canvas, {
567
+ type: 'pie',
568
+ data: {
569
+ labels: ['Hits', 'Misses'],
570
+ datasets: [
571
+ {
572
+ data: [hits, misses],
573
+ backgroundColor: ['#10b981', '#ef4444'],
574
+ borderColor: 'transparent',
575
+ },
576
+ ],
577
+ },
578
+ options: {
579
+ responsive: true,
580
+ maintainAspectRatio: true,
581
+ plugins: {
582
+ legend: {display: false},
583
+ tooltip: {
584
+ callbacks: {
585
+ label: (context) => {
586
+ const value = context.raw as number;
587
+ const total = hits + misses;
588
+ const pct = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
589
+ return `${context.label ?? ''}: ${value} (${pct}%)`;
590
+ },
591
+ },
592
+ },
593
+ },
594
+ },
595
+ });
596
+
597
+ cachePieInstances.set(canvas, chart);
598
+ }
@@ -0,0 +1,3 @@
1
+ import '../js/api.js';
2
+ import '../js/app.js';
3
+ import './tailwind.css';
@@ -0,0 +1,41 @@
1
+ @import 'tailwindcss';
2
+
3
+ @theme {
4
+ --color-primary: #3b82f6;
5
+ --color-success: #10b981;
6
+ --color-warning: #f59e0b;
7
+ --color-danger: #ef4444;
8
+ }
9
+
10
+ body {
11
+ font-family:
12
+ 'Inter',
13
+ -apple-system,
14
+ BlinkMacSystemFont,
15
+ 'Segoe UI',
16
+ Roboto,
17
+ Helvetica,
18
+ Arial,
19
+ sans-serif;
20
+ -webkit-font-smoothing: antialiased;
21
+ }
22
+
23
+ body.dark {
24
+ --bg-main: #0f172a;
25
+ --bg-side: #1e293b;
26
+ --bg-card: #1e293b;
27
+ --bg-hover: #334155;
28
+ --text-main: #94a3b8;
29
+ --text-bright: #f8fafc;
30
+ --border: #334155;
31
+ }
32
+
33
+ body:not(.dark) {
34
+ --bg-main: #f8fafc;
35
+ --bg-side: #ffffff;
36
+ --bg-card: #ffffff;
37
+ --bg-hover: #f1f5f9;
38
+ --text-main: #475569;
39
+ --text-bright: #0f172a;
40
+ --border: #e2e8f0;
41
+ }
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#3b82f6">
2
+ <path d="M12 2C6.48 2 2 4.02 2 6.5s4.48 4.5 10 4.5 10-2.02 10-4.5S17.52 2 12 2zm0 13c-5.52 0-10-2.02-10-4.5V14c0 2.48 4.48 4.5 10 4.5s10-2.02 10-4.5v-3.5c0 2.48-4.48 4.5-10 4.5zm0 4.5c-5.52 0-10-2.02-10-4.5V19c0 2.48 4.48 4.5 10 4.5s10-2.02 10-4.5v-2.5c0 2.48-4.48 4.5-10 4.5z"/>
3
+ </svg>