forge_runtime/dashboard/
assets.rs

1use axum::http::{StatusCode, header};
2use axum::response::{IntoResponse, Response};
3
4/// Dashboard asset handlers.
5pub struct DashboardAssets;
6
7/// CSS styles.
8pub async fn styles_css() -> Response {
9    let css = r#"
10:root {
11    --bg-primary: #0f0f0f;
12    --bg-secondary: #1a1a1a;
13    --bg-tertiary: #252525;
14    --text-primary: #ffffff;
15    --text-secondary: #a0a0a0;
16    --accent: #3b82f6;
17    --accent-hover: #2563eb;
18    --success: #22c55e;
19    --warning: #eab308;
20    --error: #ef4444;
21    --border: #333;
22    --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
23}
24
25* {
26    margin: 0;
27    padding: 0;
28    box-sizing: border-box;
29}
30
31body {
32    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
33    background: var(--bg-primary);
34    color: var(--text-primary);
35    line-height: 1.6;
36}
37
38.dashboard {
39    display: flex;
40    min-height: 100vh;
41}
42
43/* Sidebar */
44.sidebar {
45    width: 240px;
46    background: var(--bg-secondary);
47    border-right: 1px solid var(--border);
48    display: flex;
49    flex-direction: column;
50    position: fixed;
51    height: 100vh;
52}
53
54.sidebar-header {
55    padding: 20px;
56    border-bottom: 1px solid var(--border);
57}
58
59.sidebar-header h1 {
60    font-size: 1.5rem;
61    font-weight: 700;
62}
63
64.sidebar-header .version {
65    font-size: 0.75rem;
66    color: var(--text-secondary);
67}
68
69.nav-links {
70    list-style: none;
71    padding: 10px 0;
72    flex: 1;
73}
74
75.nav-links li a {
76    display: block;
77    padding: 12px 20px;
78    color: var(--text-secondary);
79    text-decoration: none;
80    transition: all 0.2s;
81}
82
83.nav-links li a:hover,
84.nav-links li a.active {
85    background: var(--bg-tertiary);
86    color: var(--text-primary);
87    border-left: 3px solid var(--accent);
88}
89
90.sidebar-footer {
91    padding: 20px;
92    border-top: 1px solid var(--border);
93}
94
95.sidebar-footer a {
96    color: var(--text-secondary);
97    text-decoration: none;
98    font-size: 0.875rem;
99}
100
101/* Main Content */
102.content {
103    margin-left: 240px;
104    flex: 1;
105    min-height: 100vh;
106}
107
108.content-header {
109    display: flex;
110    justify-content: space-between;
111    align-items: center;
112    padding: 20px 30px;
113    background: var(--bg-secondary);
114    border-bottom: 1px solid var(--border);
115    position: sticky;
116    top: 0;
117    z-index: 100;
118}
119
120.content-header h2 {
121    font-size: 1.5rem;
122    font-weight: 600;
123}
124
125.header-actions {
126    display: flex;
127    gap: 10px;
128}
129
130.content-body {
131    padding: 30px;
132}
133
134/* Stats Grid */
135.stats-grid {
136    display: grid;
137    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
138    gap: 20px;
139    margin-bottom: 30px;
140}
141
142.stat-card {
143    background: var(--bg-secondary);
144    border-radius: 8px;
145    padding: 20px;
146    display: flex;
147    gap: 15px;
148    border: 1px solid var(--border);
149}
150
151.stat-icon {
152    font-size: 2rem;
153}
154
155.stat-content h3 {
156    font-size: 0.875rem;
157    color: var(--text-secondary);
158    margin-bottom: 5px;
159}
160
161.stat-value {
162    font-size: 1.75rem;
163    font-weight: 700;
164}
165
166.stat-label {
167    font-size: 0.75rem;
168    color: var(--text-secondary);
169}
170
171/* Charts */
172.charts-row {
173    display: grid;
174    grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
175    gap: 20px;
176    margin-bottom: 30px;
177}
178
179.chart-container {
180    background: var(--bg-secondary);
181    border-radius: 8px;
182    padding: 20px;
183    border: 1px solid var(--border);
184}
185
186.chart-container.full-width {
187    grid-column: 1 / -1;
188}
189
190.chart-container h3 {
191    font-size: 1rem;
192    margin-bottom: 15px;
193    color: var(--text-secondary);
194}
195
196.chart-container canvas {
197    width: 100% !important;
198    height: 200px !important;
199}
200
201/* Panels */
202.panels-row {
203    display: grid;
204    grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
205    gap: 20px;
206    margin-bottom: 30px;
207}
208
209.panel {
210    background: var(--bg-secondary);
211    border-radius: 8px;
212    padding: 20px;
213    border: 1px solid var(--border);
214    margin-bottom: 20px;
215}
216
217.panel h3 {
218    font-size: 1rem;
219    margin-bottom: 15px;
220}
221
222/* Forms */
223.btn {
224    padding: 8px 16px;
225    border-radius: 6px;
226    border: none;
227    cursor: pointer;
228    font-size: 0.875rem;
229    transition: all 0.2s;
230}
231
232.btn-primary {
233    background: var(--accent);
234    color: white;
235}
236
237.btn-primary:hover {
238    background: var(--accent-hover);
239}
240
241.btn-secondary {
242    background: var(--bg-tertiary);
243    color: var(--text-primary);
244    border: 1px solid var(--border);
245}
246
247.btn-secondary:hover {
248    background: var(--border);
249}
250
251.search-input,
252.select-input,
253.number-input,
254.time-range-select {
255    padding: 8px 12px;
256    border-radius: 6px;
257    border: 1px solid var(--border);
258    background: var(--bg-tertiary);
259    color: var(--text-primary);
260    font-size: 0.875rem;
261}
262
263.search-input {
264    width: 300px;
265}
266
267.number-input {
268    width: 150px;
269}
270
271/* Tables */
272table {
273    width: 100%;
274    border-collapse: collapse;
275}
276
277th, td {
278    padding: 12px;
279    text-align: left;
280    border-bottom: 1px solid var(--border);
281}
282
283th {
284    font-weight: 600;
285    color: var(--text-secondary);
286    font-size: 0.75rem;
287    text-transform: uppercase;
288}
289
290tbody tr:hover {
291    background: var(--bg-tertiary);
292}
293
294/* Badges */
295.level-badge,
296.status-badge {
297    padding: 4px 8px;
298    border-radius: 4px;
299    font-size: 0.75rem;
300    font-weight: 600;
301}
302
303.level-info { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
304.level-warn { background: rgba(234, 179, 8, 0.2); color: #fbbf24; }
305.level-error { background: rgba(239, 68, 68, 0.2); color: #f87171; }
306.level-debug { background: rgba(107, 114, 128, 0.2); color: #9ca3af; }
307
308.status-ok { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
309.status-error { background: rgba(239, 68, 68, 0.2); color: #f87171; }
310
311/* Metrics */
312.metrics-controls {
313    display: flex;
314    gap: 15px;
315    margin-bottom: 20px;
316}
317
318.metrics-grid {
319    display: grid;
320    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
321    gap: 15px;
322    margin-bottom: 30px;
323}
324
325.metric-card {
326    background: var(--bg-secondary);
327    border-radius: 8px;
328    padding: 15px;
329    border: 1px solid var(--border);
330    cursor: pointer;
331    transition: all 0.2s;
332}
333
334.metric-card:hover {
335    border-color: var(--accent);
336}
337
338.metric-card h4 {
339    font-size: 0.875rem;
340    margin-bottom: 8px;
341    word-break: break-all;
342}
343
344.metric-value {
345    font-size: 1.5rem;
346    font-weight: 700;
347    margin-bottom: 5px;
348}
349
350.metric-type {
351    font-size: 0.75rem;
352    color: var(--text-secondary);
353}
354
355.metric-sparkline {
356    height: 40px;
357    margin-top: 10px;
358}
359
360/* Logs */
361.logs-controls {
362    display: flex;
363    gap: 15px;
364    margin-bottom: 20px;
365}
366
367.logs-table-container {
368    background: var(--bg-secondary);
369    border-radius: 8px;
370    border: 1px solid var(--border);
371    overflow-x: auto;
372    margin-bottom: 20px;
373}
374
375.log-time {
376    font-family: monospace;
377    white-space: nowrap;
378}
379
380.log-message {
381    max-width: 500px;
382    overflow: hidden;
383    text-overflow: ellipsis;
384    white-space: nowrap;
385}
386
387.logs-pagination {
388    display: flex;
389    justify-content: center;
390    align-items: center;
391    gap: 20px;
392}
393
394/* Traces */
395.traces-controls {
396    display: flex;
397    gap: 15px;
398    margin-bottom: 20px;
399    flex-wrap: wrap;
400}
401
402.checkbox-label {
403    display: flex;
404    align-items: center;
405    gap: 8px;
406    color: var(--text-secondary);
407}
408
409.trace-timeline {
410    background: var(--bg-secondary);
411    border-radius: 8px;
412    padding: 20px;
413    border: 1px solid var(--border);
414    margin-bottom: 20px;
415}
416
417.timeline-header {
418    display: grid;
419    grid-template-columns: 120px 200px 1fr;
420    gap: 20px;
421    padding-bottom: 10px;
422    border-bottom: 1px solid var(--border);
423    font-size: 0.75rem;
424    color: var(--text-secondary);
425    text-transform: uppercase;
426}
427
428.timeline-row {
429    display: grid;
430    grid-template-columns: 120px 200px 1fr;
431    gap: 20px;
432    padding: 10px 0;
433    border-bottom: 1px solid var(--border);
434    align-items: center;
435}
436
437.timeline-bar {
438    background: var(--accent);
439    height: 20px;
440    border-radius: 4px;
441    position: relative;
442    font-size: 0.75rem;
443    display: flex;
444    align-items: center;
445    padding-left: 8px;
446    color: white;
447}
448
449/* Alerts */
450.alerts-summary {
451    display: flex;
452    gap: 20px;
453    margin-bottom: 20px;
454}
455
456.alert-stat {
457    display: flex;
458    flex-direction: column;
459    align-items: center;
460    padding: 15px 30px;
461    background: var(--bg-secondary);
462    border-radius: 8px;
463    border: 1px solid var(--border);
464}
465
466.alert-stat.critical { border-color: var(--error); }
467.alert-stat.warning { border-color: var(--warning); }
468
469.alert-stat .count {
470    font-size: 2rem;
471    font-weight: 700;
472}
473
474.alert-stat .label {
475    font-size: 0.875rem;
476    color: var(--text-secondary);
477}
478
479/* Tabs */
480.tabs {
481    display: flex;
482    gap: 5px;
483    margin-bottom: 20px;
484    border-bottom: 1px solid var(--border);
485    padding-bottom: 5px;
486}
487
488.tab {
489    padding: 10px 20px;
490    background: transparent;
491    border: none;
492    color: var(--text-secondary);
493    cursor: pointer;
494    border-radius: 6px 6px 0 0;
495}
496
497.tab:hover {
498    color: var(--text-primary);
499}
500
501.tab.active {
502    background: var(--bg-secondary);
503    color: var(--text-primary);
504}
505
506/* Jobs */
507.jobs-stats,
508.workflows-stats {
509    display: flex;
510    gap: 20px;
511    margin-bottom: 20px;
512}
513
514.job-stat,
515.workflow-stat {
516    display: flex;
517    flex-direction: column;
518    align-items: center;
519    padding: 15px 30px;
520    background: var(--bg-secondary);
521    border-radius: 8px;
522    border: 1px solid var(--border);
523}
524
525.job-stat.error,
526.workflow-stat.error {
527    border-color: var(--error);
528}
529
530.job-stat .count,
531.workflow-stat .count {
532    font-size: 2rem;
533    font-weight: 700;
534}
535
536.job-stat .label,
537.workflow-stat .label {
538    font-size: 0.875rem;
539    color: var(--text-secondary);
540}
541
542/* Cluster */
543.cluster-health {
544    display: flex;
545    justify-content: space-between;
546    align-items: center;
547    padding: 20px;
548    background: var(--bg-secondary);
549    border-radius: 8px;
550    border: 1px solid var(--border);
551    margin-bottom: 20px;
552}
553
554.health-indicator {
555    display: flex;
556    align-items: center;
557    gap: 10px;
558    font-weight: 600;
559}
560
561.health-indicator.healthy .health-icon {
562    color: var(--success);
563}
564
565.cluster-info {
566    display: flex;
567    gap: 15px;
568    color: var(--text-secondary);
569}
570
571.nodes-grid {
572    display: grid;
573    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
574    gap: 20px;
575    margin-bottom: 20px;
576}
577
578.node-card {
579    background: var(--bg-secondary);
580    border-radius: 8px;
581    padding: 20px;
582    border: 1px solid var(--border);
583}
584
585.node-card.leader {
586    border-color: var(--accent);
587}
588
589.node-header {
590    display: flex;
591    align-items: center;
592    gap: 10px;
593    margin-bottom: 15px;
594}
595
596.node-status {
597    width: 10px;
598    height: 10px;
599    border-radius: 50%;
600}
601
602.node-status.online {
603    background: var(--success);
604}
605
606.node-status.offline {
607    background: var(--error);
608}
609
610.leader-badge {
611    background: rgba(59, 130, 246, 0.2);
612    color: #60a5fa;
613    padding: 2px 8px;
614    border-radius: 4px;
615    font-size: 0.75rem;
616    margin-left: auto;
617}
618
619.node-details p {
620    font-size: 0.875rem;
621    margin-bottom: 5px;
622    color: var(--text-secondary);
623}
624
625.node-metrics {
626    margin-top: 15px;
627}
628
629.node-metric {
630    display: flex;
631    align-items: center;
632    gap: 10px;
633    margin-bottom: 8px;
634}
635
636.metric-bar {
637    flex: 1;
638    height: 8px;
639    background: var(--bg-tertiary);
640    border-radius: 4px;
641    overflow: hidden;
642}
643
644.metric-fill {
645    height: 100%;
646    background: var(--accent);
647}
648
649/* Empty State */
650.empty-state {
651    text-align: center;
652    padding: 40px;
653    color: var(--text-secondary);
654}
655
656.empty-state .subtitle {
657    font-size: 0.875rem;
658    margin-top: 10px;
659}
660
661.empty-row td {
662    text-align: center;
663    color: var(--text-secondary);
664    padding: 40px;
665}
666
667/* Modal */
668.modal {
669    position: fixed;
670    top: 0;
671    left: 0;
672    width: 100%;
673    height: 100%;
674    background: rgba(0, 0, 0, 0.7);
675    z-index: 1000;
676    display: flex;
677    align-items: center;
678    justify-content: center;
679}
680
681.modal-content {
682    background: var(--bg-secondary);
683    border-radius: 8px;
684    width: 90%;
685    max-width: 700px;
686    max-height: 80vh;
687    overflow: hidden;
688    box-shadow: var(--shadow);
689}
690
691.modal-header {
692    display: flex;
693    justify-content: space-between;
694    align-items: center;
695    padding: 16px 20px;
696    border-bottom: 1px solid var(--border);
697}
698
699.modal-header h3 {
700    margin: 0;
701    font-size: 1.1rem;
702}
703
704.modal-close {
705    background: none;
706    border: none;
707    color: var(--text-secondary);
708    font-size: 1.5rem;
709    cursor: pointer;
710    padding: 0;
711    line-height: 1;
712}
713
714.modal-close:hover {
715    color: var(--text-primary);
716}
717
718.modal-body {
719    padding: 20px;
720    overflow-y: auto;
721    max-height: calc(80vh - 60px);
722}
723
724.detail-grid {
725    display: grid;
726    grid-template-columns: 140px 1fr;
727    gap: 12px;
728}
729
730.detail-label {
731    color: var(--text-secondary);
732    font-weight: 500;
733}
734
735.detail-value {
736    color: var(--text-primary);
737}
738
739.detail-value.error {
740    color: var(--error);
741}
742
743/* Progress bar */
744.progress-bar {
745    width: 100%;
746    height: 20px;
747    background: var(--bg-tertiary);
748    border-radius: 4px;
749    overflow: hidden;
750    position: relative;
751}
752
753.progress-bar-fill {
754    height: 100%;
755    background: var(--accent);
756    transition: width 0.3s ease;
757}
758
759.progress-bar-text {
760    position: absolute;
761    top: 50%;
762    left: 50%;
763    transform: translate(-50%, -50%);
764    font-size: 0.75rem;
765    color: var(--text-primary);
766}
767
768.progress-inline {
769    display: flex;
770    align-items: center;
771    gap: 8px;
772}
773
774.progress-inline .progress-bar {
775    width: 80px;
776    height: 8px;
777}
778
779.progress-inline .progress-percent {
780    font-size: 0.8rem;
781    color: var(--text-secondary);
782    min-width: 35px;
783}
784
785/* Clickable rows */
786.clickable-row {
787    cursor: pointer;
788    transition: background 0.2s;
789}
790
791.clickable-row:hover {
792    background: var(--bg-tertiary);
793}
794
795/* Workflow steps */
796.workflow-steps {
797    margin-top: 20px;
798}
799
800.workflow-steps h4 {
801    margin-bottom: 12px;
802    color: var(--text-secondary);
803}
804
805.step-item {
806    display: flex;
807    align-items: center;
808    gap: 12px;
809    padding: 10px 12px;
810    background: var(--bg-tertiary);
811    border-radius: 4px;
812    margin-bottom: 8px;
813}
814
815.step-icon {
816    width: 24px;
817    height: 24px;
818    border-radius: 50%;
819    display: flex;
820    align-items: center;
821    justify-content: center;
822    font-size: 0.75rem;
823}
824
825.step-icon.completed { background: var(--success); color: white; }
826.step-icon.running { background: var(--accent); color: white; }
827.step-icon.pending { background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); }
828.step-icon.failed { background: var(--error); color: white; }
829
830.step-name {
831    flex: 1;
832    font-weight: 500;
833}
834
835.step-status {
836    font-size: 0.8rem;
837    color: var(--text-secondary);
838}
839
840.step-time {
841    font-size: 0.75rem;
842    color: var(--text-secondary);
843}
844
845/* Responsive */
846@media (max-width: 768px) {
847    .sidebar {
848        display: none;
849    }
850
851    .content {
852        margin-left: 0;
853    }
854
855    .stats-grid,
856    .charts-row,
857    .panels-row {
858        grid-template-columns: 1fr;
859    }
860}
861"#;
862
863    (StatusCode::OK, [(header::CONTENT_TYPE, "text/css")], css).into_response()
864}
865
866/// Main JavaScript.
867pub async fn main_js() -> Response {
868    let js = r#"
869// FORGE Dashboard JavaScript
870
871let currentPage = 1;
872const pageSize = 50;
873
874document.addEventListener('DOMContentLoaded', function() {
875    initDashboard();
876    setInterval(refreshData, 5000); // Refresh every 5 seconds
877});
878
879function initDashboard() {
880    refreshData();
881    setupEventHandlers();
882    loadPageSpecificData();
883    if (document.getElementById('requests-chart')) {
884        initCharts();
885    }
886}
887
888function getTimeRange() {
889    const select = document.getElementById('time-range');
890    return select ? select.value : '1h';
891}
892
893function setupEventHandlers() {
894    const timeRange = document.getElementById('time-range');
895    if (timeRange) {
896        timeRange.addEventListener('change', function() {
897            refreshData();
898            loadPageSpecificData();
899        });
900    }
901
902    const refreshBtn = document.getElementById('refresh-btn');
903    if (refreshBtn) {
904        refreshBtn.addEventListener('click', function() {
905            refreshData();
906            loadPageSpecificData();
907        });
908    }
909
910    const tabs = document.querySelectorAll('.tab');
911    tabs.forEach(tab => {
912        tab.addEventListener('click', function() {
913            tabs.forEach(t => t.classList.remove('active'));
914            this.classList.add('active');
915        });
916    });
917
918    // Metrics page handlers
919    const metricSearch = document.getElementById('metric-search');
920    const metricType = document.getElementById('metric-type');
921    if (metricSearch) {
922        metricSearch.addEventListener('input', debounce(() => loadMetrics(), 300));
923    }
924    if (metricType) {
925        metricType.addEventListener('change', () => loadMetrics());
926    }
927
928    // Logs page handlers
929    const logSearch = document.getElementById('log-search');
930    const logLevel = document.getElementById('log-level');
931    const liveStreamBtn = document.getElementById('live-stream-btn');
932    if (logSearch) {
933        logSearch.addEventListener('input', debounce(() => loadLogs(), 300));
934    }
935    if (logLevel) {
936        logLevel.addEventListener('change', () => loadLogs());
937    }
938    if (liveStreamBtn) {
939        liveStreamBtn.addEventListener('click', toggleLiveStream);
940    }
941
942    // Traces page handlers
943    const traceSearch = document.getElementById('trace-search');
944    const minDuration = document.getElementById('min-duration');
945    const errorsOnly = document.getElementById('errors-only');
946    if (traceSearch) {
947        traceSearch.addEventListener('input', debounce(() => loadTraces(), 300));
948    }
949    if (minDuration) {
950        minDuration.addEventListener('input', debounce(() => loadTraces(), 300));
951    }
952    if (errorsOnly) {
953        errorsOnly.addEventListener('change', () => loadTraces());
954    }
955
956    // Modal handlers - close on background click or escape key
957    document.addEventListener('click', function(e) {
958        if (e.target.classList.contains('modal')) {
959            e.target.style.display = 'none';
960        }
961    });
962    document.addEventListener('keydown', function(e) {
963        if (e.key === 'Escape') {
964            const jobModal = document.getElementById('job-modal');
965            const workflowModal = document.getElementById('workflow-modal');
966            const metricModal = document.getElementById('metric-modal');
967            if (jobModal) jobModal.style.display = 'none';
968            if (workflowModal) workflowModal.style.display = 'none';
969            if (metricModal) metricModal.style.display = 'none';
970        }
971    });
972}
973
974// Utility: debounce for search inputs
975function debounce(fn, delay) {
976    let timeout;
977    return function(...args) {
978        clearTimeout(timeout);
979        timeout = setTimeout(() => fn.apply(this, args), delay);
980    };
981}
982
983function loadPageSpecificData() {
984    const path = window.location.pathname;
985    if (path.includes('/metrics')) loadMetrics();
986    else if (path.includes('/logs')) loadLogs();
987    else if (path.includes('/traces') && !path.includes('/traces/')) loadTraces();
988    else if (path.includes('/jobs')) loadJobs();
989    else if (path.includes('/workflows')) loadWorkflows();
990    else if (path.includes('/crons')) loadCrons();
991    else if (path.includes('/cluster')) loadCluster();
992}
993
994async function refreshData() {
995    try {
996        const [stats, alerts, logs, health, nodes] = await Promise.all([
997            fetch('/_api/system/stats').then(r => r.json()).catch(() => null),
998            fetch('/_api/alerts/active').then(r => r.json()).catch(() => null),
999            fetch('/_api/logs?limit=10').then(r => r.json()).catch(() => null),
1000            fetch('/_api/cluster/health').then(r => r.json()).catch(() => null),
1001            fetch('/_api/cluster/nodes').then(r => r.json()).catch(() => null),
1002        ]);
1003
1004        if (stats?.success) updateStats(stats.data);
1005        if (alerts?.success) updateAlerts(alerts.data);
1006        if (logs?.success) updateRecentLogs(logs.data);
1007        if (health?.success) updateClusterHealth(health.data);
1008        if (nodes?.success) updateClusterNodes(nodes.data);
1009    } catch (error) {
1010        console.error('Failed to refresh data:', error);
1011    }
1012}
1013
1014function updateStats(data) {
1015    setText('stat-requests', data.http_requests_per_second?.toFixed(1) || '0');
1016    setText('stat-connections', data.active_connections || '0');
1017    setText('stat-latency', data.p99_latency_ms != null ? data.p99_latency_ms.toFixed(1) : '-');
1018    setText('stat-errors', '0%');
1019}
1020
1021function updateAlerts(alerts) {
1022    const container = document.getElementById('active-alerts');
1023    if (!container) return;
1024    if (!alerts || alerts.length === 0) {
1025        container.innerHTML = '<p class="empty-state">No active alerts</p>';
1026        return;
1027    }
1028    container.innerHTML = alerts.map(alert => `
1029        <div class="alert-item ${alert.severity}">
1030            <span class="alert-severity">${alert.severity.toUpperCase()}</span>
1031            <span class="alert-message">${alert.message || alert.name}</span>
1032        </div>
1033    `).join('');
1034}
1035
1036function updateRecentLogs(logs) {
1037    const container = document.getElementById('recent-logs');
1038    if (!container) return;
1039    if (!logs || logs.length === 0) {
1040        container.innerHTML = '<p class="empty-state">No recent logs</p>';
1041        return;
1042    }
1043    container.innerHTML = logs.map(log => `
1044        <div class="log-item ${log.level}">
1045            <span class="log-time">${formatTime(log.timestamp)}</span>
1046            <span class="level-badge level-${log.level}">${log.level.toUpperCase()}</span>
1047            <span class="log-message">${escapeHtml(log.message)}</span>
1048        </div>
1049    `).join('');
1050}
1051
1052function updateClusterHealth(health) {
1053    const indicator = document.getElementById('health-indicator');
1054    const icon = document.getElementById('health-icon');
1055    const text = document.getElementById('health-text');
1056    const nodeCount = document.getElementById('node-count');
1057    const leaderInfo = document.getElementById('leader-info');
1058
1059    if (indicator) indicator.className = `health-indicator ${health.status}`;
1060    if (icon) icon.textContent = health.status === 'healthy' ? '✓' : health.status === 'degraded' ? '!' : '✗';
1061    if (text) text.textContent = health.status === 'healthy' ? 'Cluster Healthy' : health.status === 'degraded' ? 'Cluster Degraded' : 'Cluster Unhealthy';
1062    if (nodeCount) nodeCount.textContent = `${health.node_count} Node${health.node_count !== 1 ? 's' : ''}`;
1063    if (leaderInfo) leaderInfo.textContent = `Leader: ${health.leader_node || 'None'}`;
1064
1065    const leaderTbody = document.getElementById('leadership-tbody');
1066    if (leaderTbody && health.leaders) {
1067        const leaders = Object.entries(health.leaders);
1068        if (leaders.length === 0) {
1069            leaderTbody.innerHTML = '<tr class="empty-row"><td colspan="2">No leaders elected</td></tr>';
1070        } else {
1071            leaderTbody.innerHTML = leaders.map(([role, nodeId]) => `
1072                <tr><td>${role}</td><td>${nodeId}</td></tr>
1073            `).join('');
1074        }
1075    }
1076}
1077
1078function updateClusterNodes(nodes) {
1079    const container = document.getElementById('nodes-grid');
1080    const overviewContainer = document.getElementById('cluster-nodes');
1081
1082    const html = (!nodes || nodes.length === 0)
1083        ? '<p class="empty-state">No nodes registered</p>'
1084        : nodes.map(node => `
1085            <div class="node-card ${node.status === 'active' ? '' : 'offline'}">
1086                <div class="node-header">
1087                    <span class="node-status ${node.status === 'active' ? 'online' : 'offline'}"></span>
1088                    <h4>${escapeHtml(node.name)}</h4>
1089                </div>
1090                <div class="node-details">
1091                    <p><strong>Roles:</strong> ${node.roles?.join(', ') || 'None'}</p>
1092                    <p><strong>Version:</strong> ${node.version || 'Unknown'}</p>
1093                    <p><strong>Started:</strong> ${formatTime(node.started_at)}</p>
1094                    <p><strong>Last Heartbeat:</strong> ${formatRelativeTime(node.last_heartbeat)}</p>
1095                </div>
1096            </div>
1097        `).join('');
1098
1099    if (container) container.innerHTML = html;
1100    if (overviewContainer) overviewContainer.innerHTML = html;
1101}
1102
1103// Metrics page
1104async function loadMetrics() {
1105    const container = document.getElementById('metrics-list');
1106    if (!container) return;
1107
1108    // Get filter values
1109    const searchQuery = document.getElementById('metric-search')?.value?.toLowerCase() || '';
1110    const typeFilter = document.getElementById('metric-type')?.value || '';
1111
1112    try {
1113        const res = await fetch('/_api/metrics').then(r => r.json());
1114        if (!res.success || !res.data || res.data.length === 0) {
1115            container.innerHTML = '<p class="empty-state">No metrics recorded yet</p>';
1116            return;
1117        }
1118
1119        // Apply filters
1120        let metrics = res.data;
1121        if (searchQuery) {
1122            metrics = metrics.filter(m => m.name.toLowerCase().includes(searchQuery));
1123        }
1124        if (typeFilter) {
1125            metrics = metrics.filter(m => m.kind === typeFilter);
1126        }
1127
1128        if (metrics.length === 0) {
1129            container.innerHTML = '<p class="empty-state">No metrics match your filters</p>';
1130            return;
1131        }
1132
1133        container.innerHTML = metrics.map(m => `
1134            <div class="metric-card" onclick="selectMetric('${escapeHtml(m.name)}')">
1135                <h4>${escapeHtml(m.name)}</h4>
1136                <p class="metric-value">${formatMetricValue(m.current_value, m.kind)}</p>
1137                <p class="metric-type">${m.kind}</p>
1138            </div>
1139        `).join('');
1140    } catch (e) {
1141        container.innerHTML = '<p class="empty-state">Failed to load metrics</p>';
1142    }
1143}
1144
1145function formatMetricValue(value, kind) {
1146    if (kind === 'histogram' || kind === 'summary') return value.toFixed(4) + 's';
1147    if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
1148    if (value >= 1000) return (value / 1000).toFixed(1) + 'K';
1149    return value.toFixed(value % 1 === 0 ? 0 : 2);
1150}
1151
1152async function selectMetric(name) {
1153    const modal = document.getElementById('metric-modal');
1154    const title = document.getElementById('metric-modal-title');
1155    const body = document.getElementById('metric-modal-body');
1156
1157    if (!modal || !body) return;
1158
1159    modal.style.display = 'flex';
1160    title.textContent = name;
1161    body.innerHTML = 'Loading...';
1162
1163    try {
1164        // Fetch metric details
1165        const res = await fetch(`/_api/metrics/${encodeURIComponent(name)}`).then(r => r.json());
1166        if (!res.success) {
1167            body.innerHTML = `<p class="error">Failed to load metric: ${escapeHtml(res.error || 'Unknown error')}</p>`;
1168            return;
1169        }
1170
1171        const metric = res.data;
1172        body.innerHTML = `
1173            <div id="metric-detail-chart-container" style="height: 200px; margin-bottom: 16px;">
1174                <canvas id="metric-detail-chart"></canvas>
1175            </div>
1176            <div class="detail-grid">
1177                <span class="detail-label">Name</span>
1178                <span class="detail-value">${escapeHtml(metric.name)}</span>
1179
1180                <span class="detail-label">Type</span>
1181                <span class="detail-value">${metric.kind || 'counter'}</span>
1182
1183                <span class="detail-label">Current Value</span>
1184                <span class="detail-value">${formatMetricValue(metric.current_value || 0, metric.kind)}</span>
1185
1186                <span class="detail-label">Last Updated</span>
1187                <span class="detail-value">${metric.timestamp ? formatTime(metric.timestamp) : '-'}</span>
1188            </div>
1189            ${Object.keys(metric.labels || {}).length > 0 ? `
1190                <h4 style="margin-top: 16px;">Labels</h4>
1191                <pre style="background: var(--bg-tertiary); padding: 12px; border-radius: 4px; overflow-x: auto;">${JSON.stringify(metric.labels, null, 2)}</pre>
1192            ` : ''}
1193        `;
1194
1195        // Load time series for this metric
1196        const seriesRes = await fetch(`/_api/metrics/series?name=${encodeURIComponent(name)}&period=${getTimeRange()}`).then(r => r.json());
1197        if (seriesRes.success && seriesRes.data && seriesRes.data.length > 0) {
1198            const metricSeries = seriesRes.data.find(s => s.name === name) || seriesRes.data[0];
1199            if (metricSeries && metricSeries.points && metricSeries.points.length > 0) {
1200                renderChart('metric-detail-chart', metricSeries.points, '#3b82f6', name);
1201            }
1202        }
1203    } catch (e) {
1204        body.innerHTML = `<p class="error">Failed to load metric details: ${escapeHtml(e.message)}</p>`;
1205    }
1206}
1207
1208function closeMetricModal() {
1209    const modal = document.getElementById('metric-modal');
1210    if (modal) modal.style.display = 'none';
1211}
1212
1213// Logs page
1214let logStreamEventSource = null;
1215
1216async function loadLogs() {
1217    const tbody = document.getElementById('logs-tbody');
1218    if (!tbody) return;
1219
1220    // Get filter values
1221    const searchQuery = document.getElementById('log-search')?.value?.toLowerCase() || '';
1222    const level = document.getElementById('log-level')?.value || '';
1223    const period = getTimeRange();
1224
1225    try {
1226        const url = `/_api/logs?limit=${pageSize}&period=${period}` + (level ? `&level=${level}` : '');
1227        const res = await fetch(url).then(r => r.json());
1228
1229        if (!res.success || !res.data || res.data.length === 0) {
1230            tbody.innerHTML = '<tr class="empty-row"><td colspan="4">No logs found</td></tr>';
1231            return;
1232        }
1233
1234        // Apply search filter client-side
1235        let logs = res.data;
1236        if (searchQuery) {
1237            logs = logs.filter(log =>
1238                log.message?.toLowerCase().includes(searchQuery) ||
1239                log.trace_id?.toLowerCase().includes(searchQuery)
1240            );
1241        }
1242
1243        if (logs.length === 0) {
1244            tbody.innerHTML = '<tr class="empty-row"><td colspan="4">No logs match your search</td></tr>';
1245            return;
1246        }
1247
1248        tbody.innerHTML = logs.map(log => `
1249            <tr class="log-row log-${log.level}">
1250                <td class="log-time">${formatTime(log.timestamp)}</td>
1251                <td class="log-level"><span class="level-badge level-${log.level}">${log.level.toUpperCase()}</span></td>
1252                <td class="log-message">${escapeHtml(log.message)}</td>
1253                <td class="log-trace">${log.trace_id ? `<a href="/_dashboard/traces/${log.trace_id}">${log.trace_id.substring(0, 8)}</a>` : '-'}</td>
1254            </tr>
1255        `).join('');
1256    } catch (e) {
1257        tbody.innerHTML = '<tr class="empty-row"><td colspan="4">Failed to load logs</td></tr>';
1258    }
1259}
1260
1261function toggleLiveStream() {
1262    const btn = document.getElementById('live-stream-btn');
1263    const tbody = document.getElementById('logs-tbody');
1264
1265    if (logStreamEventSource) {
1266        // Stop streaming
1267        logStreamEventSource.close();
1268        logStreamEventSource = null;
1269        if (btn) {
1270            btn.textContent = 'Start Live Stream';
1271            btn.classList.remove('streaming');
1272        }
1273        return;
1274    }
1275
1276    // Start streaming via SSE
1277    if (btn) {
1278        btn.textContent = 'Stop Live Stream';
1279        btn.classList.add('streaming');
1280    }
1281
1282    const level = document.getElementById('log-level')?.value || '';
1283    const url = '/_api/logs/stream' + (level ? `?level=${level}` : '');
1284
1285    logStreamEventSource = new EventSource(url);
1286
1287    logStreamEventSource.onmessage = function(event) {
1288        try {
1289            const log = JSON.parse(event.data);
1290            if (!tbody) return;
1291
1292            const row = document.createElement('tr');
1293            row.className = `log-row log-${log.level}`;
1294            row.innerHTML = `
1295                <td class="log-time">${formatTime(log.timestamp)}</td>
1296                <td class="log-level"><span class="level-badge level-${log.level}">${log.level.toUpperCase()}</span></td>
1297                <td class="log-message">${escapeHtml(log.message)}</td>
1298                <td class="log-trace">${log.trace_id ? `<a href="/_dashboard/traces/${log.trace_id}">${log.trace_id.substring(0, 8)}</a>` : '-'}</td>
1299            `;
1300
1301            // Insert at top (newest first)
1302            tbody.insertBefore(row, tbody.firstChild);
1303
1304            // Limit displayed rows
1305            while (tbody.children.length > 100) {
1306                tbody.removeChild(tbody.lastChild);
1307            }
1308        } catch (e) {
1309            console.error('Failed to parse log event:', e);
1310        }
1311    };
1312
1313    logStreamEventSource.onerror = function(e) {
1314        console.error('Log stream error:', e);
1315        // Auto-reconnect after 3 seconds
1316        setTimeout(() => {
1317            if (logStreamEventSource && logStreamEventSource.readyState === EventSource.CLOSED) {
1318                toggleLiveStream(); // Stop
1319                toggleLiveStream(); // Restart
1320            }
1321        }, 3000);
1322    };
1323}
1324
1325// Traces page
1326async function loadTraces() {
1327    const tbody = document.getElementById('traces-tbody');
1328    if (!tbody) return;
1329
1330    // Get filter values
1331    const searchQuery = document.getElementById('trace-search')?.value?.toLowerCase() || '';
1332    const minDuration = parseInt(document.getElementById('min-duration')?.value) || 0;
1333    const errorsOnly = document.getElementById('errors-only')?.checked || false;
1334    const period = getTimeRange();
1335
1336    try {
1337        const url = `/_api/traces?limit=${pageSize}&period=${period}` + (errorsOnly ? '&errors_only=true' : '');
1338        const res = await fetch(url).then(r => r.json());
1339
1340        if (!res.success || !res.data || res.data.length === 0) {
1341            tbody.innerHTML = '<tr class="empty-row"><td colspan="7">No traces found</td></tr>';
1342            return;
1343        }
1344
1345        // Apply filters client-side
1346        let traces = res.data;
1347
1348        if (searchQuery) {
1349            traces = traces.filter(t =>
1350                t.trace_id?.toLowerCase().includes(searchQuery) ||
1351                t.service?.toLowerCase().includes(searchQuery) ||
1352                t.root_span_name?.toLowerCase().includes(searchQuery)
1353            );
1354        }
1355
1356        if (minDuration > 0) {
1357            traces = traces.filter(t => t.duration_ms >= minDuration);
1358        }
1359
1360        if (traces.length === 0) {
1361            tbody.innerHTML = '<tr class="empty-row"><td colspan="7">No traces match your filters</td></tr>';
1362            return;
1363        }
1364
1365        tbody.innerHTML = traces.map(t => `
1366            <tr class="trace-row ${t.error ? 'trace-error' : ''}">
1367                <td class="trace-id"><a href="/_dashboard/traces/${t.trace_id}">${t.trace_id.substring(0, 12)}</a></td>
1368                <td class="trace-name">${escapeHtml(t.root_span_name || 'Unknown')}</td>
1369                <td class="trace-service">${escapeHtml(t.service)}</td>
1370                <td class="trace-duration">${t.duration_ms}ms</td>
1371                <td class="trace-spans">${t.span_count}</td>
1372                <td class="trace-status"><span class="status-badge ${t.error ? 'status-error' : 'status-ok'}">${t.error ? 'ERROR' : 'OK'}</span></td>
1373                <td class="trace-time">${formatTime(t.started_at)}</td>
1374            </tr>
1375        `).join('');
1376    } catch (e) {
1377        tbody.innerHTML = '<tr class="empty-row"><td colspan="7">Failed to load traces</td></tr>';
1378    }
1379}
1380
1381// Trace detail page
1382async function loadTraceDetail(traceId) {
1383    const spansContainer = document.getElementById('waterfall-body');
1384    const summary = document.getElementById('trace-summary');
1385    const spanTree = document.getElementById('span-tree');
1386
1387    if (!spansContainer) return;
1388
1389    try {
1390        const res = await fetch(`/_api/traces/${traceId}`).then(r => r.json());
1391
1392        if (!res.success || !res.data || !res.data.spans || res.data.spans.length === 0) {
1393            spansContainer.innerHTML = '<p class="empty-state">Trace not found</p>';
1394            return;
1395        }
1396
1397        const trace = res.data;
1398        const spans = trace.spans;
1399        const rootSpan = spans[0];
1400        const totalDuration = Math.max(...spans.map(s => s.duration_ms || 0));
1401        const startTime = new Date(rootSpan.start_time);
1402
1403        if (summary) {
1404            summary.textContent = `Started: ${formatTime(rootSpan.start_time)} | Duration: ${totalDuration}ms | ${spans.length} spans`;
1405        }
1406
1407        spansContainer.innerHTML = spans.map((span, i) => {
1408            const offset = span.start_time ? ((new Date(span.start_time) - startTime) / totalDuration * 100) : 0;
1409            const width = span.duration_ms ? (span.duration_ms / totalDuration * 100) : 1;
1410            const indent = span.parent_span_id ? 20 : 0;
1411            const statusClass = span.status === 'error' ? 'status-error' : '';
1412
1413            return `
1414                <div class="timeline-row ${i === 0 ? 'root' : 'child'} ${statusClass}" style="margin-left: ${indent}px;" onclick="showSpanDetails(${i})">
1415                    <span class="service">${escapeHtml(span.service || 'unknown')}</span>
1416                    <span class="operation">${escapeHtml(span.name)}</span>
1417                    <div class="timeline-bar" style="width: ${Math.max(width, 1)}%; left: ${offset}%;">${span.duration_ms || 0}ms</div>
1418                </div>
1419            `;
1420        }).join('');
1421
1422        // Render span tree
1423        if (spanTree) {
1424            spanTree.innerHTML = spans.map((span, i) => `
1425                <div class="span-tree-item ${i === 0 ? 'root' : ''}" onclick="showSpanDetails(${i})" style="padding-left: ${span.parent_span_id ? 20 : 0}px;">
1426                    <span class="span-icon">${i === 0 ? '🌳' : '├─'}</span>
1427                    <span class="span-name">${escapeHtml(span.name)}</span>
1428                    <span class="span-duration">${span.duration_ms || 0}ms</span>
1429                </div>
1430            `).join('');
1431        }
1432
1433        window.traceSpans = spans;
1434    } catch (e) {
1435        spansContainer.innerHTML = '<p class="empty-state">Failed to load trace</p>';
1436    }
1437}
1438
1439function showSpanDetails(index) {
1440    const span = window.traceSpans?.[index];
1441    const container = document.getElementById('span-details');
1442    if (!span || !container) return;
1443
1444    container.innerHTML = `
1445        <h4>Span: ${escapeHtml(span.name)}</h4>
1446        <table>
1447            <tr><td><strong>Span ID:</strong></td><td>${span.span_id}</td></tr>
1448            <tr><td><strong>Parent:</strong></td><td>${span.parent_span_id || 'Root'}</td></tr>
1449            <tr><td><strong>Service:</strong></td><td>${span.service}</td></tr>
1450            <tr><td><strong>Kind:</strong></td><td>${span.kind}</td></tr>
1451            <tr><td><strong>Status:</strong></td><td>${span.status}</td></tr>
1452            <tr><td><strong>Duration:</strong></td><td>${span.duration_ms || 0}ms</td></tr>
1453            <tr><td><strong>Start:</strong></td><td>${formatTime(span.start_time)}</td></tr>
1454        </table>
1455        ${Object.keys(span.attributes || {}).length > 0 ? `<h5>Attributes</h5><pre>${JSON.stringify(span.attributes, null, 2)}</pre>` : ''}
1456        ${(span.events || []).length > 0 ? `<h5>Events</h5><pre>${JSON.stringify(span.events, null, 2)}</pre>` : ''}
1457    `;
1458}
1459
1460// Jobs page
1461async function loadJobs() {
1462    const tbody = document.getElementById('jobs-tbody');
1463    if (!tbody) return;
1464
1465    try {
1466        const [jobsRes, statsRes] = await Promise.all([
1467            fetch('/_api/jobs?limit=50').then(r => r.json()),
1468            fetch('/_api/jobs/stats').then(r => r.json()),
1469        ]);
1470
1471        if (statsRes.success) {
1472            const s = statsRes.data;
1473            setText('jobs-pending', s.pending);
1474            setText('jobs-running', s.running);
1475            setText('jobs-completed', s.completed);
1476            setText('jobs-failed', s.failed + s.dead_letter);
1477        }
1478
1479        if (!jobsRes.success || !jobsRes.data || jobsRes.data.length === 0) {
1480            tbody.innerHTML = '<tr class="empty-row"><td colspan="8">No jobs found</td></tr>';
1481            return;
1482        }
1483
1484        tbody.innerHTML = jobsRes.data.map(job => `
1485            <tr class="clickable-row" onclick="openJobModal('${job.id}')">
1486                <td>${job.id.substring(0, 8)}</td>
1487                <td>${escapeHtml(job.job_type)}</td>
1488                <td>${job.priority}</td>
1489                <td><span class="status-badge status-${job.status}">${job.status}</span></td>
1490                <td>${renderJobProgress(job)}</td>
1491                <td>${job.attempts}/${job.max_attempts}</td>
1492                <td>${formatTime(job.created_at)}</td>
1493                <td>${job.last_error ? escapeHtml(job.last_error.substring(0, 30)) : '-'}</td>
1494            </tr>
1495        `).join('');
1496    } catch (e) {
1497        tbody.innerHTML = '<tr class="empty-row"><td colspan="8">Failed to load jobs</td></tr>';
1498    }
1499}
1500
1501function renderJobProgress(job) {
1502    const percent = job.progress_percent;
1503    if (percent === null || percent === undefined) {
1504        if (job.status === 'completed') return '<span class="status-badge status-completed">Done</span>';
1505        if (job.status === 'pending') return '-';
1506        return '-';
1507    }
1508    return `
1509        <div class="progress-inline">
1510            <div class="progress-bar">
1511                <div class="progress-bar-fill" style="width: ${percent}%"></div>
1512            </div>
1513            <span class="progress-percent">${percent}%</span>
1514        </div>
1515    `;
1516}
1517
1518async function openJobModal(jobId) {
1519    const modal = document.getElementById('job-modal');
1520    const body = document.getElementById('job-modal-body');
1521    if (!modal || !body) return;
1522
1523    modal.style.display = 'flex';
1524    body.innerHTML = 'Loading...';
1525
1526    try {
1527        const res = await fetch(`/_api/jobs/${jobId}`).then(r => r.json());
1528        if (!res.success) {
1529            body.innerHTML = `<p class="error">Failed to load job: ${escapeHtml(res.error || 'Unknown error')}</p>`;
1530            return;
1531        }
1532
1533        const job = res.data;
1534        const progressBar = job.progress_percent !== null ? `
1535            <div class="progress-bar" style="margin-top: 8px;">
1536                <div class="progress-bar-fill" style="width: ${job.progress_percent}%"></div>
1537                <span class="progress-bar-text">${job.progress_percent}%</span>
1538            </div>
1539        ` : '';
1540
1541        body.innerHTML = `
1542            <div class="detail-grid">
1543                <span class="detail-label">Job ID</span>
1544                <span class="detail-value">${escapeHtml(job.id)}</span>
1545
1546                <span class="detail-label">Type</span>
1547                <span class="detail-value">${escapeHtml(job.job_type)}</span>
1548
1549                <span class="detail-label">Status</span>
1550                <span class="detail-value"><span class="status-badge status-${job.status}">${job.status}</span></span>
1551
1552                <span class="detail-label">Priority</span>
1553                <span class="detail-value">${job.priority}</span>
1554
1555                <span class="detail-label">Attempts</span>
1556                <span class="detail-value">${job.attempts} / ${job.max_attempts}</span>
1557
1558                <span class="detail-label">Progress</span>
1559                <span class="detail-value">${job.progress_percent !== null ? job.progress_percent + '%' : '-'}${job.progress_message ? ' - ' + escapeHtml(job.progress_message) : ''}</span>
1560
1561                <span class="detail-label">Created</span>
1562                <span class="detail-value">${formatTime(job.created_at)}</span>
1563
1564                <span class="detail-label">Started</span>
1565                <span class="detail-value">${job.started_at ? formatTime(job.started_at) : '-'}</span>
1566
1567                <span class="detail-label">Completed</span>
1568                <span class="detail-value">${job.completed_at ? formatTime(job.completed_at) : '-'}</span>
1569
1570                ${job.last_error ? `
1571                <span class="detail-label">Error</span>
1572                <span class="detail-value error">${escapeHtml(job.last_error)}</span>
1573                ` : ''}
1574            </div>
1575            ${progressBar}
1576            ${job.input ? `<h4 style="margin-top: 16px;">Input</h4><pre style="background: var(--bg-tertiary); padding: 12px; border-radius: 4px; overflow-x: auto;">${JSON.stringify(job.input, null, 2)}</pre>` : ''}
1577            ${job.output ? `<h4 style="margin-top: 16px;">Output</h4><pre style="background: var(--bg-tertiary); padding: 12px; border-radius: 4px; overflow-x: auto;">${JSON.stringify(job.output, null, 2)}</pre>` : ''}
1578        `;
1579    } catch (e) {
1580        body.innerHTML = `<p class="error">Failed to load job details</p>`;
1581    }
1582}
1583
1584function closeJobModal() {
1585    const modal = document.getElementById('job-modal');
1586    if (modal) modal.style.display = 'none';
1587}
1588
1589// Workflows page
1590async function loadWorkflows() {
1591    const tbody = document.getElementById('workflows-tbody');
1592    if (!tbody) return;
1593
1594    try {
1595        const [workflowsRes, statsRes] = await Promise.all([
1596            fetch('/_api/workflows?limit=50').then(r => r.json()),
1597            fetch('/_api/workflows/stats').then(r => r.json()),
1598        ]);
1599
1600        if (statsRes.success) {
1601            const s = statsRes.data;
1602            setText('workflows-running', s.running);
1603            setText('workflows-completed', s.completed);
1604            setText('workflows-waiting', s.waiting);
1605            setText('workflows-failed', s.failed);
1606        }
1607
1608        if (!workflowsRes.success || !workflowsRes.data || workflowsRes.data.length === 0) {
1609            tbody.innerHTML = '<tr class="empty-row"><td colspan="7">No workflow runs found</td></tr>';
1610            return;
1611        }
1612
1613        tbody.innerHTML = workflowsRes.data.map(w => `
1614            <tr class="clickable-row" onclick="openWorkflowModal('${w.id}')">
1615                <td>${w.id.substring(0, 8)}</td>
1616                <td>${escapeHtml(w.workflow_name)}</td>
1617                <td>${w.version || '-'}</td>
1618                <td><span class="status-badge status-${w.status}">${w.status}</span></td>
1619                <td>${w.current_step ? escapeHtml(w.current_step) : '-'}</td>
1620                <td>${formatTime(w.started_at)}</td>
1621                <td>${w.error ? escapeHtml(w.error.substring(0, 30)) : '-'}</td>
1622            </tr>
1623        `).join('');
1624    } catch (e) {
1625        tbody.innerHTML = '<tr class="empty-row"><td colspan="7">Failed to load workflows</td></tr>';
1626    }
1627}
1628
1629async function openWorkflowModal(workflowId) {
1630    const modal = document.getElementById('workflow-modal');
1631    const body = document.getElementById('workflow-modal-body');
1632    if (!modal || !body) return;
1633
1634    modal.style.display = 'flex';
1635    body.innerHTML = 'Loading...';
1636
1637    try {
1638        const res = await fetch(`/_api/workflows/${workflowId}`).then(r => r.json());
1639        if (!res.success) {
1640            body.innerHTML = `<p class="error">Failed to load workflow: ${escapeHtml(res.error || 'Unknown error')}</p>`;
1641            return;
1642        }
1643
1644        const wf = res.data;
1645        const stepIcons = { completed: '✓', running: '▶', pending: '○', failed: '✗', compensated: '↩' };
1646
1647        const stepsHtml = wf.steps && wf.steps.length > 0 ? `
1648            <div class="workflow-steps">
1649                <h4>Steps</h4>
1650                ${wf.steps.map(step => `
1651                    <div class="step-item">
1652                        <div class="step-icon ${step.status}">${stepIcons[step.status] || 'â—‹'}</div>
1653                        <span class="step-name">${escapeHtml(step.name)}</span>
1654                        <span class="step-status">${step.status}</span>
1655                        ${step.started_at ? `<span class="step-time">${formatRelativeTime(step.started_at)}</span>` : ''}
1656                    </div>
1657                `).join('')}
1658            </div>
1659        ` : '';
1660
1661        body.innerHTML = `
1662            <div class="detail-grid">
1663                <span class="detail-label">Run ID</span>
1664                <span class="detail-value">${escapeHtml(wf.id)}</span>
1665
1666                <span class="detail-label">Workflow</span>
1667                <span class="detail-value">${escapeHtml(wf.workflow_name)}</span>
1668
1669                <span class="detail-label">Version</span>
1670                <span class="detail-value">${wf.version || '-'}</span>
1671
1672                <span class="detail-label">Status</span>
1673                <span class="detail-value"><span class="status-badge status-${wf.status}">${wf.status}</span></span>
1674
1675                <span class="detail-label">Current Step</span>
1676                <span class="detail-value">${wf.current_step ? escapeHtml(wf.current_step) : '-'}</span>
1677
1678                <span class="detail-label">Started</span>
1679                <span class="detail-value">${wf.started_at ? formatTime(wf.started_at) : '-'}</span>
1680
1681                <span class="detail-label">Completed</span>
1682                <span class="detail-value">${wf.completed_at ? formatTime(wf.completed_at) : '-'}</span>
1683
1684                ${wf.error ? `
1685                <span class="detail-label">Error</span>
1686                <span class="detail-value error">${escapeHtml(wf.error)}</span>
1687                ` : ''}
1688            </div>
1689            ${stepsHtml}
1690            ${wf.input ? `<h4 style="margin-top: 16px;">Input</h4><pre style="background: var(--bg-tertiary); padding: 12px; border-radius: 4px; overflow-x: auto;">${JSON.stringify(wf.input, null, 2)}</pre>` : ''}
1691            ${wf.output ? `<h4 style="margin-top: 16px;">Output</h4><pre style="background: var(--bg-tertiary); padding: 12px; border-radius: 4px; overflow-x: auto;">${JSON.stringify(wf.output, null, 2)}</pre>` : ''}
1692        `;
1693    } catch (e) {
1694        body.innerHTML = `<p class="error">Failed to load workflow details</p>`;
1695    }
1696}
1697
1698function closeWorkflowModal() {
1699    const modal = document.getElementById('workflow-modal');
1700    if (modal) modal.style.display = 'none';
1701}
1702
1703// Crons page
1704async function loadCrons() {
1705    const tbody = document.getElementById('crons-tbody');
1706    const historyTbody = document.getElementById('cron-history-tbody');
1707    if (!tbody) return;
1708
1709    try {
1710        const [cronsRes, statsRes, historyRes] = await Promise.all([
1711            fetch('/_api/crons').then(r => r.json()),
1712            fetch('/_api/crons/stats').then(r => r.json()),
1713            fetch('/_api/crons/history?limit=50').then(r => r.json()),
1714        ]);
1715
1716        if (statsRes.success) {
1717            const s = statsRes.data;
1718            setText('crons-active', s.active_count || 0);
1719            setText('crons-paused', s.paused_count || 0);
1720            setText('crons-success-rate', s.success_rate_24h !== null ? s.success_rate_24h.toFixed(1) + '%' : '-');
1721            setText('crons-next-run', s.next_scheduled_run ? formatTime(s.next_scheduled_run) : '-');
1722        }
1723
1724        if (!cronsRes.success || !cronsRes.data || cronsRes.data.length === 0) {
1725            tbody.innerHTML = '<tr class="empty-row"><td colspan="8">No cron jobs found</td></tr>';
1726        } else {
1727            tbody.innerHTML = cronsRes.data.map(cron => `
1728                <tr>
1729                    <td>${escapeHtml(cron.name)}</td>
1730                    <td><code>${escapeHtml(cron.schedule || '* * * * *')}</code></td>
1731                    <td><span class="status-badge status-${cron.status || 'active'}">${cron.status || 'active'}</span></td>
1732                    <td>${cron.last_run ? formatTime(cron.last_run) : '-'}</td>
1733                    <td>${cron.last_result ? `<span class="status-badge status-${cron.last_result}">${cron.last_result}</span>` : '-'}</td>
1734                    <td>${cron.next_run ? formatTime(cron.next_run) : '-'}</td>
1735                    <td>${cron.avg_duration_ms ? cron.avg_duration_ms.toFixed(0) + 'ms' : '-'}</td>
1736                    <td>-</td>
1737                </tr>
1738            `).join('');
1739        }
1740
1741        // Load history
1742        if (historyTbody) {
1743            if (!historyRes.success || !historyRes.data || historyRes.data.length === 0) {
1744                historyTbody.innerHTML = '<tr class="empty-row"><td colspan="5">No execution history found</td></tr>';
1745            } else {
1746                historyTbody.innerHTML = historyRes.data.map(h => `
1747                    <tr>
1748                        <td>${escapeHtml(h.cron_name)}</td>
1749                        <td>${h.started_at ? formatTime(h.started_at) : '-'}</td>
1750                        <td>${h.duration_ms ? h.duration_ms.toFixed(0) + 'ms' : '-'}</td>
1751                        <td><span class="status-badge status-${h.status}">${h.status}</span></td>
1752                        <td>${h.error ? escapeHtml(h.error.substring(0, 50)) : '-'}</td>
1753                    </tr>
1754                `).join('');
1755            }
1756        }
1757    } catch (e) {
1758        console.error('Failed to load crons:', e);
1759        tbody.innerHTML = '<tr class="empty-row"><td colspan="8">Failed to load cron jobs</td></tr>';
1760        if (historyTbody) {
1761            historyTbody.innerHTML = '<tr class="empty-row"><td colspan="5">Failed to load history</td></tr>';
1762        }
1763    }
1764}
1765
1766// Cluster page
1767async function loadCluster() {
1768    try {
1769        const [nodesRes, healthRes] = await Promise.all([
1770            fetch('/_api/cluster/nodes').then(r => r.json()),
1771            fetch('/_api/cluster/health').then(r => r.json()),
1772        ]);
1773
1774        if (healthRes.success) updateClusterHealth(healthRes.data);
1775        if (nodesRes.success) updateClusterNodes(nodesRes.data);
1776    } catch (e) {
1777        console.error('Failed to load cluster data:', e);
1778    }
1779}
1780
1781// Charts
1782async function initCharts() {
1783    const period = getTimeRange();
1784    try {
1785        const res = await fetch(`/_api/metrics/series?period=${period}`).then(r => r.json());
1786        const series = res.success ? res.data : [];
1787
1788        const requestsData = series.find(s => s.name.includes('http_requests'));
1789        const latencyData = series.find(s => s.name.includes('duration') || s.name.includes('latency'));
1790
1791        renderChart('requests-chart', requestsData?.points || [], '#3b82f6', 'Requests');
1792        renderChart('latency-chart', latencyData?.points || [], '#22c55e', 'Latency (ms)');
1793    } catch (e) {
1794        renderChart('requests-chart', [], '#3b82f6', 'Requests');
1795        renderChart('latency-chart', [], '#22c55e', 'Latency (ms)');
1796    }
1797}
1798
1799function renderChart(canvasId, points, color, label) {
1800    const canvas = document.getElementById(canvasId);
1801    if (!canvas) return;
1802
1803    // Wait for Chart.js to load if not ready
1804    if (!window.Chart) {
1805        window.addEventListener('chartjs-ready', () => renderChart(canvasId, points, color, label), { once: true });
1806        return;
1807    }
1808
1809    // Destroy existing chart to prevent memory leaks
1810    if (canvas._chart) {
1811        canvas._chart.destroy();
1812    }
1813
1814    const labels = points.length > 0
1815        ? points.map(p => formatTime(p.timestamp))
1816        : Array.from({length: 20}, (_, i) => '');
1817    const data = points.length > 0
1818        ? points.map(p => p.value)
1819        : Array.from({length: 20}, () => 0);
1820
1821    const ctx = canvas.getContext('2d');
1822    const chart = new Chart(ctx, {
1823        type: 'line',
1824        data: {
1825            labels: labels.slice(-60),
1826            datasets: [{
1827                label: label,
1828                data: data.slice(-60),
1829                borderColor: color,
1830                backgroundColor: color + '20',
1831                fill: true,
1832                tension: 0.4,
1833                pointRadius: 2,
1834                pointHoverRadius: 6,
1835            }]
1836        },
1837        options: {
1838            responsive: true,
1839            maintainAspectRatio: false,
1840            interaction: {
1841                intersect: false,
1842                mode: 'index',
1843            },
1844            plugins: {
1845                legend: { display: true, position: 'top', labels: { color: '#9ca3af' } },
1846                tooltip: {
1847                    enabled: true,
1848                    backgroundColor: '#1f2937',
1849                    titleColor: '#f9fafb',
1850                    bodyColor: '#d1d5db',
1851                    borderColor: '#374151',
1852                    borderWidth: 1,
1853                    callbacks: {
1854                        label: (ctx) => ctx.dataset.label + ': ' + ctx.formattedValue
1855                    }
1856                },
1857                zoom: {
1858                    zoom: {
1859                        wheel: { enabled: true },
1860                        pinch: { enabled: true },
1861                        mode: 'x',
1862                    },
1863                    pan: {
1864                        enabled: true,
1865                        mode: 'x',
1866                    }
1867                }
1868            },
1869            scales: {
1870                x: {
1871                    grid: { color: '#333' },
1872                    ticks: { color: '#9ca3af', maxRotation: 0 }
1873                },
1874                y: {
1875                    grid: { color: '#333' },
1876                    ticks: { color: '#9ca3af' },
1877                    beginAtZero: true
1878                }
1879            },
1880            onClick: (event, elements) => {
1881                if (elements.length > 0) {
1882                    const idx = elements[0].index;
1883                    const point = points[idx];
1884                    console.log('Chart clicked:', label, point);
1885                }
1886            }
1887        }
1888    });
1889
1890    // Store reference for cleanup
1891    canvas._chart = chart;
1892}
1893
1894// Utility functions
1895function setText(id, value) {
1896    const el = document.getElementById(id);
1897    if (el) el.textContent = value;
1898}
1899
1900function escapeHtml(str) {
1901    if (!str) return '';
1902    return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1903}
1904
1905function formatTime(timestamp) {
1906    if (!timestamp) return '-';
1907    return new Date(timestamp).toLocaleTimeString();
1908}
1909
1910function formatRelativeTime(timestamp) {
1911    if (!timestamp) return 'Unknown';
1912    const diff = Date.now() - new Date(timestamp).getTime();
1913    if (diff < 5000) return 'Just now';
1914    if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
1915    if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
1916    return Math.floor(diff / 3600000) + 'h ago';
1917}
1918"#;
1919
1920    (
1921        StatusCode::OK,
1922        [(header::CONTENT_TYPE, "application/javascript")],
1923        js,
1924    )
1925        .into_response()
1926}
1927
1928/// Chart.js library loader (loads from CDN).
1929pub async fn chart_js() -> Response {
1930    // Load Chart.js from CDN with zoom plugin for interactive charts
1931    let js = r#"
1932// Chart.js CDN Loader
1933// Loads Chart.js and zoom plugin dynamically for interactive charts
1934(function(global) {
1935    // Check if already loaded
1936    if (global.Chart && global.Chart.version) {
1937        global.dispatchEvent(new Event('chartjs-ready'));
1938        return;
1939    }
1940
1941    // Chart.js CDN URL
1942    const CHARTJS_URL = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';
1943    const ZOOM_PLUGIN_URL = 'https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.1.0/dist/chartjs-plugin-zoom.min.js';
1944
1945    // Load script from URL
1946    function loadScript(url) {
1947        return new Promise((resolve, reject) => {
1948            const script = document.createElement('script');
1949            script.src = url;
1950            script.onload = resolve;
1951            script.onerror = reject;
1952            document.head.appendChild(script);
1953        });
1954    }
1955
1956    // Load Chart.js first, then zoom plugin
1957    loadScript(CHARTJS_URL)
1958        .then(() => loadScript(ZOOM_PLUGIN_URL))
1959        .then(() => {
1960            if (global.Chart) {
1961                // Register zoom plugin
1962                try {
1963                    global.Chart.register(global['chartjs-plugin-zoom']);
1964                } catch (e) {
1965                    console.warn('Could not register zoom plugin:', e);
1966                }
1967                global.dispatchEvent(new Event('chartjs-ready'));
1968                console.log('Chart.js loaded with zoom plugin');
1969            }
1970        })
1971        .catch(err => {
1972            console.error('Failed to load Chart.js from CDN:', err);
1973            // Provide minimal fallback
1974            global.Chart = createFallbackChart();
1975            global.dispatchEvent(new Event('chartjs-ready'));
1976        });
1977
1978    // Fallback chart implementation (used when CDN fails)
1979    function createFallbackChart() {
1980        function FallbackChart(ctx, config) {
1981            this.ctx = ctx;
1982            this.config = config;
1983            this.render();
1984        }
1985
1986        FallbackChart.prototype.render = function() {
1987            const ctx = this.ctx;
1988            const canvas = ctx.canvas;
1989            const data = this.config.data?.datasets?.[0]?.data || [];
1990            const color = this.config.data?.datasets?.[0]?.borderColor || '#3b82f6';
1991
1992            ctx.clearRect(0, 0, canvas.width, canvas.height);
1993
1994            if (data.length < 2) return;
1995
1996            const width = canvas.width;
1997            const height = canvas.height;
1998            const padding = 30;
1999            const chartWidth = width - 2 * padding;
2000            const chartHeight = height - 2 * padding;
2001
2002            const max = Math.max(...data) * 1.1 || 1;
2003            const min = Math.min(...data) * 0.9 || 0;
2004            const range = max - min || 1;
2005
2006            // Draw grid
2007            ctx.strokeStyle = '#333';
2008            ctx.lineWidth = 0.5;
2009            for (let i = 0; i <= 4; i++) {
2010                const y = padding + (i / 4) * chartHeight;
2011                ctx.beginPath();
2012                ctx.moveTo(padding, y);
2013                ctx.lineTo(width - padding, y);
2014                ctx.stroke();
2015            }
2016
2017            // Draw line
2018            ctx.strokeStyle = color;
2019            ctx.lineWidth = 2;
2020            ctx.beginPath();
2021
2022            data.forEach((value, i) => {
2023                const x = padding + (i / (data.length - 1)) * chartWidth;
2024                const y = height - padding - ((value - min) / range) * chartHeight;
2025                i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
2026            });
2027
2028            ctx.stroke();
2029
2030            // Fill area
2031            const lastX = padding + chartWidth;
2032            const lastY = height - padding - ((data[data.length - 1] - min) / range) * chartHeight;
2033            ctx.lineTo(lastX, height - padding);
2034            ctx.lineTo(padding, height - padding);
2035            ctx.closePath();
2036            ctx.fillStyle = this.config.data?.datasets?.[0]?.backgroundColor || 'rgba(59, 130, 246, 0.1)';
2037            ctx.fill();
2038        };
2039
2040        FallbackChart.prototype.destroy = function() { this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); };
2041        FallbackChart.prototype.update = function() { this.render(); };
2042        FallbackChart.version = 'fallback';
2043
2044        return FallbackChart;
2045    }
2046})(window);
2047"#;
2048
2049    (
2050        StatusCode::OK,
2051        [(header::CONTENT_TYPE, "application/javascript")],
2052        js,
2053    )
2054        .into_response()
2055}
2056
2057#[cfg(test)]
2058mod tests {
2059    #[test]
2060    fn test_assets_compile() {
2061        // Just verify the module compiles
2062    }
2063}