1use axum::http::{StatusCode, header};
2use axum::response::{IntoResponse, Response};
3
4pub struct DashboardAssets;
6
7pub 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
866pub 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
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
1928pub async fn chart_js() -> Response {
1930 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 }
2063}