Skip to main content

ferrox_front_ui/
json_dashboard_builder.rs

1use ferrox_front_core::dom::{div, h2, span, button as dom_button, document, style, DomBuilder};
2use ferrox_front_charts::{line_chart, bar_chart, Point, BarDatum};
3use serde::{Deserialize, Serialize};
4use wasm_bindgen::JsCast;
5use wasm_bindgen::closure::Closure;
6use wasm_bindgen::JsValue;
7use web_sys::{MouseEvent, window, ResizeObserver, ResizeObserverEntry};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DashboardConfig {
11    pub title: String,
12    pub description: Option<String>,
13    pub panels: Vec<PanelConfig>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type")]
18pub enum PanelConfig {
19    #[serde(rename = "line_chart")]
20    LineChart { title: String, data: Vec<PointData>, width: Option<u32>, height: Option<u32>, alert: Option<bool> },
21    #[serde(rename = "bar_chart")]
22    BarChart { title: String, data: Vec<BarData>, width: Option<u32>, height: Option<u32>, alert: Option<bool> },
23    #[serde(rename = "stat_badge")]
24    StatBadge { title: String, value: String, color: Option<String>, alert: Option<bool> },
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PointData { pub x: f64, pub y: f64 }
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct BarData { pub label: String, pub value: f64, pub color: String }
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34struct LayoutItem {
35    id: String,
36    span: u8,
37    #[serde(default = "default_row_span")]
38    row_span: u8,
39    hidden: bool,
40    #[serde(default)]
41    muted: bool,
42}
43
44fn default_row_span() -> u8 {
45    4
46}
47
48fn get_panel_id(panel: &PanelConfig) -> String {
49    let title = match panel {
50        PanelConfig::LineChart { title, .. } => title,
51        PanelConfig::BarChart { title, .. } => title,
52        PanelConfig::StatBadge { title, .. } => title,
53    };
54    title.replace(" ", "").replace("(", "").replace(")", "").replace(".", "")
55}
56
57pub fn render_json_dashboard(json_str: &str) -> DomBuilder {
58    let config: Result<DashboardConfig, _> = serde_json::from_str(json_str);
59    match config {
60        Ok(cfg) => build_dashboard(cfg),
61        Err(e) => div().text(&format!("Error: {}", e)),
62    }
63}
64
65fn build_dashboard(cfg: DashboardConfig) -> DomBuilder {
66    setup_global_resize_listeners();
67    setup_resize_observer();
68    
69    if let Ok(json) = serde_json::to_string(&cfg) {
70        if let Some(w) = web_sys::window() {
71            if let Ok(Some(ls)) = w.local_storage() {
72                let _ = ls.set_item("current_dashboard_data", &json);
73            }
74        }
75    }
76
77    let css = r#"
78        .view-mode #dashboard-palette-container,
79        .view-mode .palette-label { display: none !important; }
80        
81        .view-mode .panel-controls { display: none !important; }
82        
83        .view-mode .ferrox-dashboard-panel { cursor: pointer !important; }
84        .edit-mode .ferrox-dashboard-panel { cursor: grab !important; }
85        
86        .ferrox-dashboard-panel {
87            position: relative;
88            min-width: 0; 
89            overflow: hidden;
90            display: flex; 
91            flex-direction: column;
92            transition: box-shadow 0.2s ease-in-out, border 0.2s ease-in-out;
93        }
94
95        .edit-mode {
96            outline: 2px dashed rgba(6, 182, 212, 0.3);
97            outline-offset: 4px;
98        }
99
100        .resize-handle-right {
101            position: absolute;
102            right: 0;
103            top: 0;
104            bottom: 0;
105            width: 10px;
106            cursor: col-resize;
107            background: transparent;
108            z-index: 10;
109        }
110        .resize-handle-right:hover {
111            background: rgba(6, 182, 212, 0.3);
112        }
113        
114        .resize-handle-bottom {
115            position: absolute;
116            bottom: 0;
117            left: 0;
118            right: 0;
119            height: 10px;
120            cursor: row-resize;
121            background: transparent;
122            z-index: 10;
123        }
124        .resize-handle-bottom:hover {
125            background: rgba(6, 182, 212, 0.3);
126        }
127        
128        .view-mode .resize-handle-right,
129        .view-mode .resize-handle-bottom { display: none !important; }
130
131        .panel-header-title {
132            font-weight: 600; 
133            color: var(--text-primary); 
134            white-space: nowrap; 
135            overflow: hidden; 
136            text-overflow: ellipsis;
137            min-width: 0;
138            flex-shrink: 1;
139        }
140
141        .ferrox-btn {
142            background-color: var(--accent-cyan, #06b6d4);
143            color: #000;
144            border: none;
145            border-radius: 6px;
146            padding: 8px 16px;
147            font-weight: 600;
148            cursor: pointer;
149            transition: all 0.2s;
150        }
151        .ferrox-btn:hover {
152            background-color: #0891b2;
153            transform: translateY(-1px);
154        }
155
156        @keyframes pulse-alert {
157            0% { box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.7); }
158            70% { box-shadow: 0 0 0 10px rgba(244, 63, 94, 0); }
159            100% { box-shadow: 0 0 0 0 rgba(244, 63, 94, 0); }
160        }
161        
162        .panel-alert {
163            border: 1px solid rgba(244, 63, 94, 0.8) !important;
164            animation: pulse-alert 2s infinite;
165        }
166    "#;
167
168    let style_dom = style().text(css);
169
170    let mut container = div()
171        .attr("id", "dashboard-main-view")
172        .attr("class", "ferrox-json-dashboard view-mode")
173        .attr("style", "display: flex; flex-direction: column; gap: 24px; padding: 20px; background: var(--bg-primary, #0a0a16); border-radius: 12px; border: var(--border-glass);")
174        .child(style_dom);
175
176    let toggle_edit_btn = dom_button()
177        .attr("id", "edit-mode-toggle")
178        .attr("class", "ferrox-btn primary")
179        .attr("style", "padding: 6px 12px; font-size: 0.9rem;")
180        .text("Edit Layout")
181        .on_click(|| {
182            let doc = document();
183            if let Some(main) = doc.get_element_by_id("dashboard-main-view") {
184                let current_class = main.class_name();
185                if current_class.contains("edit-mode") {
186                    main.set_class_name(&current_class.replace("edit-mode", "view-mode"));
187                    if let Some(btn) = doc.get_element_by_id("edit-mode-toggle") {
188                        btn.set_text_content(Some("Edit Layout"));
189                    }
190                } else {
191                    main.set_class_name(&current_class.replace("view-mode", "edit-mode"));
192                    if let Some(btn) = doc.get_element_by_id("edit-mode-toggle") {
193                        btn.set_text_content(Some("Save Layout"));
194                    }
195                }
196            }
197        });
198
199    let mute_btn = dom_button()
200        .attr("id", "mute-toggle-btn")
201        .attr("class", "ferrox-btn secondary")
202        .attr("style", "padding: 6px 12px; font-size: 0.9rem; margin-right: 8px; background: rgba(255,255,255,0.1); color: white; border-radius: 6px; border: none; cursor: pointer;")
203        .text("🔊 Unmuted")
204        .on_click(|| {
205            let doc = document();
206            if let Some(body) = doc.body() {
207                let current = body.get_attribute("data-muted").unwrap_or("false".to_string());
208                if current == "true" {
209                    body.set_attribute("data-muted", "false").unwrap();
210                    if let Some(btn) = doc.get_element_by_id("mute-toggle-btn") {
211                        btn.set_text_content(Some("🔊 Unmuted"));
212                    }
213                } else {
214                    body.set_attribute("data-muted", "true").unwrap();
215                    if let Some(btn) = doc.get_element_by_id("mute-toggle-btn") {
216                        btn.set_text_content(Some("🔇 Muted"));
217                    }
218                }
219            }
220        });
221
222    let reset_layout_btn = dom_button()
223        .attr("class", "ferrox-btn secondary")
224        .attr("style", "padding: 6px 12px; font-size: 0.9rem; margin-right: 8px; background: rgba(244,63,94,0.2); color: #f43f5e; border-radius: 6px; border: 1px solid rgba(244,63,94,0.3); cursor: pointer;")
225        .text("Reset Layout")
226        .on_click(|| {
227            if let Some(w) = web_sys::window() {
228                if let Ok(Some(ls)) = w.local_storage() {
229                    let _ = ls.remove_item("ferrox_layout");
230                    let _ = w.location().reload();
231                }
232            }
233        });
234
235    let header_actions = div().attr("style", "display: flex; align-items: center;")
236        .child(reset_layout_btn)
237        .child(mute_btn)
238        .child(toggle_edit_btn);
239
240    let header_row = div().attr("style", "display: flex; justify-content: space-between; align-items: center;")
241        .child(h2().attr("style", "color: var(--text-primary); font-size: 1.5rem; margin: 0;").text(&cfg.title))
242        .child(header_actions);
243
244    container = container.child(header_row);
245
246    if let Some(desc) = cfg.description {
247        container = container.child(div().attr("style", "color: var(--text-muted); font-size: 0.95rem; margin-top: -16px;").text(&desc));
248    }
249
250    let palette = div()
251        .attr("id", "dashboard-palette-container")
252        .attr("style", "display: flex; gap: 12px; min-height: 50px; padding: 12px; background: rgba(255,255,255,0.02); border-radius: 8px; border: 1px dashed rgba(255,255,255,0.1); overflow-x: auto; align-items: center;")
253        .on("dragover", |e| { e.prevent_default(); })
254        .on("drop", |e| { handle_drop(e, "dashboard-palette-container"); });
255
256    let palette_label = span().attr("class", "palette-label").attr("style", "color: var(--text-muted); font-size: 0.85rem;").text("Hidden Panels (Drag & Drop to Grid)");
257    container = container.child(div().child(palette_label).child(palette));
258
259    let mut panels_grid = div()
260        .attr("id", "dashboard-grid-container")
261        .attr("style", "display: grid; grid-template-columns: repeat(24, 1fr); grid-auto-rows: max(35px, calc((100vh - 400px) / 12)); gap: 20px; min-height: 300px; padding: 10px; border-radius: 8px; background: rgba(0,0,0,0.1);")
262        .on("dragover", |e| { e.prevent_default(); })
263        .on("drop", |e| { handle_drop(e, "dashboard-grid-container"); });
264
265    let state_json = web_sys::window().unwrap().local_storage().unwrap().unwrap().get_item("ferrox_layout").unwrap_or(None);
266    let mut layout_state: Vec<LayoutItem> = match state_json {
267        Some(json) => serde_json::from_str::<Vec<LayoutItem>>(&json).unwrap_or_default(),
268        None => Vec::new(),
269    };
270
271    if layout_state.is_empty() || layout_state.len() != cfg.panels.len() {
272        let order_and_spans = vec![
273            ("ServerCPUUsage%", 4, 3),
274            ("ServerRAMUsage%", 4, 3),
275            ("OnlinePlayersac_world_online_players", 4, 3),
276            ("TotalPacketsac_world_packets_total", 4, 3),
277            ("MemoryRSS", 4, 3),
278            ("AveragePing", 4, 3),
279            ("WorldUpdateDurations", 12, 9),
280            ("DatabaseQueueSize", 4, 9),
281            ("NetworkTrafficMB", 4, 9),
282            ("UpdatePhasess", 4, 9),
283        ];
284
285        let mut new_layout = Vec::new();
286        for (id_str, span, row_span) in order_and_spans {
287            if cfg.panels.iter().any(|p| get_panel_id(p) == id_str) {
288                new_layout.push(LayoutItem {
289                    id: id_str.to_string(),
290                    span,
291                    row_span,
292                    hidden: false,
293                    muted: false,
294                });
295            }
296        }
297        
298        for p in &cfg.panels {
299            let id = get_panel_id(p);
300            if !new_layout.iter().any(|item| item.id == id) {
301                new_layout.push(LayoutItem {
302                    id,
303                    span: 6,
304                    row_span: 4,
305                    hidden: false,
306                    muted: false,
307                });
308            }
309        }
310        
311        layout_state = new_layout;
312        save_layout_state(&layout_state);
313    }
314
315    let mut sorted_panels = Vec::new();
316    for layout_item in &layout_state {
317        if let Some(panel) = cfg.panels.iter().find(|p| get_panel_id(p) == layout_item.id) {
318            sorted_panels.push((panel.clone(), layout_item.clone()));
319        }
320    }
321
322    for (panel, item_state) in sorted_panels {
323        let panel_dom = build_panel_dom(panel, item_state.span, item_state.row_span, item_state.hidden, item_state.muted);
324        panels_grid = panels_grid.child(panel_dom);
325    }
326
327    container.child(panels_grid)
328}
329
330fn get_panel_alert(config: &PanelConfig) -> bool {
331    match config {
332        PanelConfig::LineChart { alert, .. } => alert.unwrap_or(false),
333        PanelConfig::BarChart { alert, .. } => alert.unwrap_or(false),
334        PanelConfig::StatBadge { alert, .. } => alert.unwrap_or(false),
335    }
336}
337
338fn build_panel_content(config: &PanelConfig, id: &str) -> DomBuilder {
339    let (actual_w, actual_h) = if let Some(el) = document().get_element_by_id(&format!("panel-content-{}", id)) {
340        (el.client_width().max(50) as u32, el.client_height().max(50) as u32)
341    } else {
342        (0, 0)
343    };
344
345    match config.clone() {
346        PanelConfig::LineChart { data, width, height, .. } => {
347            let points: Vec<Point> = data.into_iter().map(|p| Point { x: p.x, y: p.y }).collect();
348            let final_w = if actual_w > 0 { actual_w } else { width.unwrap_or(300) };
349            let final_h = if actual_h > 0 { actual_h } else { height.unwrap_or(200) };
350            line_chart(id, points, final_w, final_h, true)
351        }
352        PanelConfig::BarChart { data, width, height, .. } => {
353            let bars: Vec<BarDatum> = data.into_iter().map(|b| BarDatum { label: b.label, value: b.value, color: b.color }).collect();
354            let final_w = if actual_w > 0 { actual_w } else { width.unwrap_or(300) };
355            let final_h = if actual_h > 0 { actual_h } else { height.unwrap_or(200) };
356            bar_chart(id, bars, final_w, final_h, true)
357        }
358        PanelConfig::StatBadge { value, color, .. } => {
359            let badge_color = color.unwrap_or_else(|| "var(--accent-cyan, #06b6d4)".to_string());
360            div().attr("style", &format!("font-size: 2rem; font-weight: 700; color: {}; text-align: center; word-break: break-all;", badge_color)).text(&value)
361        }
362    }
363}
364
365fn build_panel_dom(config: PanelConfig, col_span: u8, row_span: u8, is_hidden: bool, is_muted: bool) -> DomBuilder {
366    let id = get_panel_id(&config);
367    let title_str = match &config {
368        PanelConfig::LineChart { title, .. } => title.clone(),
369        PanelConfig::BarChart { title, .. } => title.clone(),
370        PanelConfig::StatBadge { title, .. } => title.clone(),
371    };
372
373    let content = build_panel_content(&config, &id);
374
375    let panel_id_clone = id.clone();
376    
377    let header = div()
378        .attr("style", "display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 8px; margin-bottom: 12px; cursor: grab; gap: 8px;")
379        .child(span().attr("class", "panel-header-title").text(&title_str));
380
381    let controls = div().attr("class", "panel-controls").attr("style", "display: flex; gap: 4px; flex-shrink: 0;");
382    
383    let btn_style = "background: rgba(255,255,255,0.1); border: none; color: white; border-radius: 4px; width: 22px; height: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px;";
384    
385    let id_c = id.clone();
386    let btn_reset = dom_button().attr("style", btn_style).attr("title", "Reset Size").text("↺").on_click(move || reset_panel_span(&id_c));
387    let id_c = id.clone();
388    let btn_hide = dom_button().attr("style", btn_style).attr("title", "Hide Panel").text("x").on_click(move || toggle_panel_visibility(&id_c));
389    let id_c = id.clone();
390    let btn_mute = dom_button()
391        .attr("id", &format!("panel-mute-btn-{}", id_c))
392        .attr("style", btn_style)
393        .attr("title", "Toggle Panel Alert Mute")
394        .text(if is_muted { "🔕" } else { "🔔" })
395        .on_click(move || toggle_panel_mute(&id_c));
396
397    let header = header.child(controls.child(btn_mute).child(btn_reset).child(btn_hide));
398    
399    let style = if is_hidden {
400        "display: flex; min-width: 150px; max-width: 200px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; cursor: grab; align-items: center;"
401    } else {
402        &format!("grid-column: span {}; grid-row: span {}; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);", col_span, row_span)
403    };
404
405    let title_string = title_str.to_string();
406    let config_clone = config.clone();
407    let panel_id_drop_clone = id.clone();
408    
409    let is_alert = get_panel_alert(&config);
410    let mut base_classes = "ferrox-dashboard-panel".to_string();
411    if is_alert {
412        base_classes.push_str(" panel-alert");
413    }
414
415    let handle_id = id.clone();
416    let resize_handle = div()
417        .attr("class", "resize-handle-right")
418        .on("mousedown", move |e| {
419            if let Some(mouse_event) = e.dyn_ref::<MouseEvent>() {
420                mouse_event.prevent_default();
421                mouse_event.stop_propagation();
422                if let Some(body) = document().body() {
423                    body.set_attribute("data-resizing-panel-x", &format!("panel-{}", handle_id)).unwrap();
424                    body.set_attribute("data-resize-start-x", &mouse_event.client_x().to_string()).unwrap();
425                }
426            }
427        });
428
429    let handle_id_y = id.clone();
430    let resize_handle_bottom = div()
431        .attr("class", "resize-handle-bottom")
432        .on("mousedown", move |e| {
433            if let Some(mouse_event) = e.dyn_ref::<MouseEvent>() {
434                mouse_event.prevent_default();
435                mouse_event.stop_propagation();
436                if let Some(body) = document().body() {
437                    body.set_attribute("data-resizing-panel-y", &format!("panel-{}", handle_id_y)).unwrap();
438                    body.set_attribute("data-resize-start-y", &mouse_event.client_y().to_string()).unwrap();
439                }
440            }
441        });
442
443    let content_container = div()
444        .attr("id", &format!("panel-content-{}", id))
445        .attr("style", "flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; min-width: 0; position: relative;")
446        .on_click(move || {
447            let d = document();
448            if let Some(main) = d.get_element_by_id("dashboard-main-view") {
449                if !main.class_name().contains("edit-mode") {
450                    navigate_to_detail(&title_string, config_clone.clone());
451                }
452            }
453        })
454        .child(content);
455
456    let content_node = content_container.build();
457
458    if let Some(w) = window() {
459        if let Ok(ro_val) = js_sys::Reflect::get(&w, &JsValue::from_str("global_resize_observer")) {
460            if ro_val.is_object() {
461                let ro: ResizeObserver = ro_val.unchecked_into();
462                if let Some(element) = content_node.dyn_ref::<web_sys::Element>() {
463                    ro.observe(element);
464                }
465            }
466        }
467    }
468
469    div()
470        .attr("id", &format!("panel-{}", id))
471        .attr("class", &base_classes)
472        .attr("data-span", &col_span.to_string())
473        .attr("data-row-span", &row_span.to_string())
474        .attr("data-hidden", if is_hidden { "true" } else { "false" })
475        .attr("data-muted", if is_muted { "true" } else { "false" })
476        .attr("style", style)
477        .attr("draggable", "true")
478        .on("dragstart", move |e| {
479            if let Some(drag_event) = e.dyn_ref::<web_sys::DragEvent>() {
480                if let Some(dt) = drag_event.data_transfer() {
481                    let _ = dt.set_data("text/plain", &panel_id_clone);
482                }
483            }
484        })
485        .on("dragover", |e| {
486            e.prevent_default(); 
487        })
488        .on("drop", move |e| {
489            e.prevent_default();
490            e.stop_propagation();
491            if let Some(drag_event) = e.dyn_ref::<web_sys::DragEvent>() {
492                if let Some(dt) = drag_event.data_transfer() {
493                    if let Ok(dragged_id) = dt.get_data("text/plain") {
494                        swap_panels(&dragged_id, &panel_id_drop_clone);
495                    }
496                }
497            }
498        })
499        .child(resize_handle)
500        .child(resize_handle_bottom)
501        .child(header)
502        .child(DomBuilder::from_node(content_node))
503}
504
505fn handle_drop(e: web_sys::Event, container_id: &str) {
506    e.prevent_default();
507    if let Some(drag_event) = e.dyn_ref::<web_sys::DragEvent>() {
508        if let Some(dt) = drag_event.data_transfer() {
509            if let Ok(dragged_id) = dt.get_data("text/plain") {
510                let doc = document();
511                if let Some(dragged_el) = doc.get_element_by_id(&format!("panel-{}", dragged_id)) {
512                    if let Some(container) = doc.get_element_by_id(container_id) {
513                        let _ = container.append_child(&dragged_el);
514                        
515                        let is_hidden = container_id == "dashboard-palette-container";
516                        dragged_el.set_attribute("data-hidden", if is_hidden { "true" } else { "false" }).unwrap();
517                        
518                        if is_hidden {
519                            dragged_el.set_attribute("style", "display: flex; min-width: 150px; max-width: 200px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; cursor: grab; align-items: center;").unwrap();
520                        } else {
521                            let span = dragged_el.get_attribute("data-span").unwrap_or("6".to_string());
522                            let row_span = dragged_el.get_attribute("data-row-span").unwrap_or("4".to_string());
523                            dragged_el.set_attribute("style", &format!("position: relative; grid-column: span {}; grid-row: span {}; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);", span, row_span)).unwrap();
524                        }
525
526                        sync_layout_state();
527                    }
528                }
529            }
530        }
531    }
532}
533
534fn swap_panels(dragged_id: &str, target_id: &str) {
535    if dragged_id == target_id { return; }
536    let doc = document();
537    let dragged_el = doc.get_element_by_id(&format!("panel-{}", dragged_id));
538    let target_el = doc.get_element_by_id(&format!("panel-{}", target_id));
539    
540    if let (Some(dragged), Some(target)) = (dragged_el, target_el) {
541        if let Some(parent) = target.parent_node() {
542            let dragged_next = dragged.next_sibling();
543            
544            let _ = parent.insert_before(&dragged, Some(&target));
545            
546            if let Some(next) = dragged_next {
547                let _ = parent.insert_before(&target, Some(&next));
548            } else {
549                let _ = parent.append_child(&target);
550            }
551            
552            sync_layout_state();
553        }
554    }
555}
556
557fn reset_panel_span(id: &str) {
558    let doc = document();
559    if let Some(panel) = doc.get_element_by_id(&format!("panel-{}", id)) {
560        let span = 6;
561        let row_span = 4;
562        panel.set_attribute("data-span", &span.to_string()).unwrap();
563        panel.set_attribute("data-row-span", &row_span.to_string()).unwrap();
564        if panel.get_attribute("data-hidden").unwrap_or("false".to_string()) == "false" {
565            panel.set_attribute("style", &format!("position: relative; grid-column: span {}; grid-row: span {}; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);", span, row_span)).unwrap();
566        }
567        sync_layout_state();
568    }
569}
570
571fn toggle_panel_visibility(id: &str) {
572    let doc = document();
573    if let Some(panel) = doc.get_element_by_id(&format!("panel-{}", id)) {
574        let is_hidden = panel.get_attribute("data-hidden").unwrap_or("false".to_string()) == "true";
575        if is_hidden {
576            if let Some(grid) = doc.get_element_by_id("dashboard-grid-container") {
577                grid.append_child(&panel).unwrap();
578                panel.set_attribute("data-hidden", "false").unwrap();
579                let span = panel.get_attribute("data-span").unwrap_or("6".to_string());
580                let row_span = panel.get_attribute("data-row-span").unwrap_or("4".to_string());
581                panel.set_attribute("style", &format!("position: relative; grid-column: span {}; grid-row: span {}; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);", span, row_span)).unwrap();
582            }
583        } else {
584            if let Some(palette) = doc.get_element_by_id("dashboard-palette-container") {
585                palette.append_child(&panel).unwrap();
586                panel.set_attribute("data-hidden", "true").unwrap();
587                panel.set_attribute("style", "display: flex; min-width: 150px; max-width: 200px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; cursor: grab; align-items: center;").unwrap();
588            }
589        }
590        sync_layout_state();
591    }
592}
593
594fn toggle_panel_mute(id: &str) {
595    let doc = document();
596    if let Some(panel) = doc.get_element_by_id(&format!("panel-{}", id)) {
597        let is_muted = panel.get_attribute("data-muted").unwrap_or("false".to_string()) == "true";
598        if is_muted {
599            panel.set_attribute("data-muted", "false").unwrap();
600            if let Some(btn) = doc.get_element_by_id(&format!("panel-mute-btn-{}", id)) {
601                btn.set_text_content(Some("🔔"));
602            }
603        } else {
604            panel.set_attribute("data-muted", "true").unwrap();
605            if let Some(btn) = doc.get_element_by_id(&format!("panel-mute-btn-{}", id)) {
606                btn.set_text_content(Some("🔕"));
607            }
608            // Remove alert styling if it's currently alerting
609            let current_classes = panel.class_name();
610            if current_classes.contains("panel-alert") {
611                panel.set_class_name(&current_classes.replace("panel-alert", "").trim());
612            }
613        }
614        sync_layout_state();
615    }
616}
617
618fn sync_layout_state() {
619    let doc = document();
620    let mut layout = Vec::new();
621    
622    if let Some(grid) = doc.get_element_by_id("dashboard-grid-container") {
623        if let Some(html_grid) = grid.dyn_ref::<web_sys::HtmlElement>() {
624            let children = html_grid.children();
625            for i in 0..children.length() {
626                let el = children.item(i).unwrap();
627                if let Some(id_attr) = el.id().strip_prefix("panel-") {
628                    let span = el.get_attribute("data-span").unwrap_or("6".to_string()).parse().unwrap_or(6);
629                    let row_span = el.get_attribute("data-row-span").unwrap_or("4".to_string()).parse().unwrap_or(4);
630                    let muted = el.get_attribute("data-muted").unwrap_or("false".to_string()) == "true";
631                    layout.push(LayoutItem { id: id_attr.to_string(), span, row_span, hidden: false, muted });
632                }
633            }
634        }
635    }
636    
637    if let Some(palette) = doc.get_element_by_id("dashboard-palette-container") {
638        if let Some(html_palette) = palette.dyn_ref::<web_sys::HtmlElement>() {
639            let children = html_palette.children();
640            for i in 0..children.length() {
641                let el = children.item(i).unwrap();
642                if let Some(id_attr) = el.id().strip_prefix("panel-") {
643                    let span = el.get_attribute("data-span").unwrap_or("6".to_string()).parse().unwrap_or(6);
644                    let row_span = el.get_attribute("data-row-span").unwrap_or("4".to_string()).parse().unwrap_or(4);
645                    let muted = el.get_attribute("data-muted").unwrap_or("false".to_string()) == "true";
646                    layout.push(LayoutItem { id: id_attr.to_string(), span, row_span, hidden: true, muted });
647                }
648            }
649        }
650    }
651    
652    save_layout_state(&layout);
653}
654
655fn save_layout_state(layout: &[LayoutItem]) {
656    if let Ok(json) = serde_json::to_string(layout) {
657        if let Some(w) = web_sys::window() {
658            if let Ok(Some(ls)) = w.local_storage() {
659                let _ = ls.set_item("ferrox_layout", &json);
660            }
661        }
662    }
663}
664
665fn navigate_to_detail(title: &str, config: PanelConfig) {
666    let doc = document();
667
668    if let Some(main_view) = doc.get_element_by_id("dashboard-main-view") {
669        main_view.set_attribute("style", "display: none;").unwrap();
670    }
671
672    if let Some(old_detail) = doc.get_element_by_id("dashboard-detail-view") {
673        old_detail.remove();
674    }
675
676    let mut raw_data_dom = div().attr("style", "display: flex; flex-direction: column; gap: 8px; font-family: monospace; font-size: 0.9rem;");
677    let mut stats_dom = div().attr("style", "display: flex; gap: 24px; padding: 16px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; margin-bottom: 24px;");
678
679    let large_chart = match config {
680        PanelConfig::LineChart { data, .. } => {
681            stats_dom = stats_dom.child(span().text(&format!("Data Points: {}", data.len())));
682            stats_dom = stats_dom.child(span().text(&format!("Latest Value: {:.3}", data.last().map(|p| p.y).unwrap_or(0.0))));
683            stats_dom = stats_dom.child(span().text("Frequency: 1s"));
684
685            for (i, p) in data.iter().rev().take(10).enumerate() {
686                raw_data_dom = raw_data_dom.child(
687                    div().attr("style", "display: grid; grid-template-columns: 100px 1fr; border-bottom: 1px solid rgba(255,255,255,0.1); padding: 4px 0;")
688                        .child(span().attr("style", "color: var(--accent-cyan);").text(&format!("T-{}s", i)))
689                        .child(span().text(&format!("{:.4}", p.y)))
690                );
691            }
692
693            let id = format!("{}_detail", title.replace(" ", "").replace("(", "").replace(")", ""));
694            let points: Vec<Point> = data.into_iter().map(|p| Point { x: p.x, y: p.y }).collect();
695            line_chart(&id, points, 1200, 500, true)
696        }
697        PanelConfig::BarChart { data, .. } => {
698            stats_dom = stats_dom.child(span().text(&format!("Categories: {}", data.len())));
699            
700            for b in data.iter() {
701                raw_data_dom = raw_data_dom.child(
702                    div().attr("style", "display: grid; grid-template-columns: 150px 1fr; border-bottom: 1px solid rgba(255,255,255,0.1); padding: 4px 0;")
703                        .child(span().attr("style", &format!("color: {};", b.color)).text(&b.label))
704                        .child(span().text(&format!("{:.4}", b.value)))
705                    );
706            }
707
708            let id = format!("{}_detail", title.replace(" ", "").replace("(", "").replace(")", ""));
709            let bars: Vec<BarDatum> = data.into_iter().map(|b| BarDatum { label: b.label, value: b.value, color: b.color }).collect();
710            bar_chart(&id, bars, 1200, 500, true)
711        }
712        PanelConfig::StatBadge { value, color, .. } => {
713            div()
714                .attr("style", &format!("font-size: 8rem; font-weight: 700; color: {}; text-align: center; padding: 120px;", color.unwrap_or_else(|| "var(--accent-cyan, #06b6d4)".to_string())))
715                .text(&value)
716        }
717    };
718
719    let back_btn = dom_button()
720        .attr("class", "ferrox-btn primary")
721        .text("← Back to Dashboard")
722        .attr("style", "margin-bottom: 24px; align-self: flex-start; box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);")
723        .on_click(|| {
724            let d = document();
725            if let Some(detail) = d.get_element_by_id("dashboard-detail-view") {
726                detail.remove();
727            }
728            if let Some(main) = d.get_element_by_id("dashboard-main-view") {
729                let current_class = main.class_name();
730                main.set_attribute("style", "display: flex; flex-direction: column; gap: 24px; padding: 20px; background: var(--bg-primary, #0a0a16); border-radius: 12px; border: var(--border-glass);").unwrap();
731                main.set_class_name(&current_class);
732                
733                let mut hidden_panels = Vec::new();
734                if let Some(grid) = d.get_element_by_id("dashboard-grid-container") {
735                    if let Some(html_grid) = grid.dyn_ref::<web_sys::HtmlElement>() {
736                        let children = html_grid.children();
737                        for i in 0..children.length() {
738                            let el = children.item(i).unwrap();
739                            if el.get_attribute("data-hidden").unwrap_or("false".to_string()) == "true" {
740                                hidden_panels.push(el);
741                            }
742                        }
743                    }
744                }
745                if let Some(palette) = d.get_element_by_id("dashboard-palette-container") {
746                    for panel in hidden_panels {
747                        palette.append_child(&panel).unwrap();
748                        panel.set_attribute("style", "display: flex; min-width: 150px; max-width: 200px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; cursor: grab; align-items: center;").unwrap();
749                    }
750                }
751            }
752        });
753
754    let header = h2()
755        .attr("style", "font-size: 2rem; color: var(--text-primary); margin: 0 0 8px 0;")
756        .text(title);
757
758    let detail_view = div()
759        .attr("id", "dashboard-detail-view")
760        .attr("style", "padding: 20px; background: var(--bg-primary); min-height: 100vh; display: flex; flex-direction: column;")
761        .child(back_btn)
762        .child(header)
763        .child(stats_dom)
764        .child(
765            div()
766                .attr("style", "background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.05); border-radius: 12px; padding: 20px; margin-bottom: 24px; display: flex; justify-content: center; overflow-x: auto;")
767                .child(large_chart)
768        )
769        .child(
770            div()
771                .attr("style", "background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.05); border-radius: 12px; padding: 20px;")
772                .child(span().attr("style", "font-weight: 600; font-size: 1.1rem; margin-bottom: 16px; display: block;").text("Real-time Data Stream"))
773                .child(raw_data_dom)
774        );
775
776    if let Some(app_container) = doc.get_element_by_id("app") {
777        app_container.append_child(&detail_view.build()).unwrap();
778    }
779}
780
781pub fn update_json_dashboard(json_str: &str) {
782    let config: Result<DashboardConfig, _> = serde_json::from_str(json_str);
783    if let Ok(cfg) = config {
784        
785        if let Ok(json) = serde_json::to_string(&cfg) {
786            if let Some(w) = web_sys::window() {
787                if let Ok(Some(ls)) = w.local_storage() {
788                    let _ = ls.set_item("current_dashboard_data", &json);
789                }
790            }
791        }
792        
793        let doc = document();
794        for panel in cfg.panels {
795            let id = get_panel_id(&panel);
796            
797            if let Some(content_wrapper) = doc.get_element_by_id(&format!("panel-content-{}", id)) {
798                let new_content_dom = build_panel_content(&panel, &id).build();
799                content_wrapper.set_inner_html("");
800                let _ = content_wrapper.append_child(&new_content_dom);
801            }
802            
803            if let Some(panel_el) = doc.get_element_by_id(&format!("panel-{}", id)) {
804                let is_alert = get_panel_alert(&panel);
805                let current_classes = panel_el.class_name();
806                let has_alert_class = current_classes.contains("panel-alert");
807                let panel_muted = panel_el.get_attribute("data-muted").unwrap_or("false".to_string()) == "true";
808                
809                if is_alert && !has_alert_class && !panel_muted {
810                    panel_el.set_class_name(&format!("{} panel-alert", current_classes));
811                    
812                    if let Some(body) = doc.body() {
813                        if body.get_attribute("data-muted").unwrap_or("false".to_string()) != "true" {
814                            if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3") {
815                                let _ = audio.play();
816                            }
817                        }
818                    }
819                } else if (!is_alert || panel_muted) && has_alert_class {
820                    panel_el.set_class_name(&current_classes.replace("panel-alert", "").trim());
821                }
822            }
823        }
824    }
825}
826
827fn setup_global_resize_listeners() {
828    let window = window().unwrap();
829    let doc = document();
830    
831    if let Some(body) = doc.body() {
832        if body.get_attribute("data-resize-init").is_some() {
833            return;
834        }
835        body.set_attribute("data-resize-init", "true").unwrap();
836    }
837    
838    let mousemove = Closure::wrap(Box::new(move |e: MouseEvent| {
839        let doc = document();
840        if let Some(body) = doc.body() {
841            if let Some(panel_id) = body.get_attribute("data-resizing-panel-x") {
842                let start_x: f64 = body.get_attribute("data-resize-start-x").unwrap_or("0".into()).parse().unwrap_or(0.0);
843                let current_x = e.client_x() as f64;
844                let delta_x = current_x - start_x;
845                
846                let col_width = body.client_width() as f64 / 24.0;
847                
848                if delta_x.abs() > col_width {
849                    let steps = (delta_x / col_width).trunc() as i32;
850                    if steps != 0 {
851                        if let Some(panel) = doc.get_element_by_id(&panel_id) {
852                            let mut current_span: i32 = panel.get_attribute("data-span").unwrap_or("6".into()).parse().unwrap_or(6);
853                            let row_span: i32 = panel.get_attribute("data-row-span").unwrap_or("4".into()).parse().unwrap_or(4);
854                            let next_sibling = panel.next_element_sibling();
855                            
856                            let mut next_span: Option<i32> = None;
857                            if steps > 0 {
858                                if let Some(ref next) = next_sibling {
859                                    let ns = next.get_attribute("data-span").unwrap_or("6".into()).parse().unwrap_or(6);
860                                    if ns - steps >= 2 {
861                                        next_span = Some(ns - steps);
862                                    }
863                                }
864                            }
865                            
866                            let mut can_grow = false;
867                            if steps > 0 {
868                                if next_span.is_some() || next_sibling.is_none() {
869                                    can_grow = true;
870                                }
871                            } else {
872                                can_grow = true;
873                            }
874                            
875                            if can_grow {
876                                current_span += steps;
877                                if current_span < 2 { current_span = 2; }
878                                if current_span > 24 { current_span = 24; }
879                                
880                                panel.set_attribute("data-span", &current_span.to_string()).unwrap();
881                                panel.set_attribute("style", &format!("position: relative; grid-column: span {}; grid-row: span {}; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);", current_span, row_span)).unwrap();
882                                
883                                if let (Some(next), Some(ns)) = (next_sibling, next_span) {
884                                    next.set_attribute("data-span", &ns.to_string()).unwrap();
885                                    let next_row_span = next.get_attribute("data-row-span").unwrap_or("4".into()).parse().unwrap_or(4);
886                                    next.set_attribute("style", &format!("position: relative; grid-column: span {}; grid-row: span {}; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);", ns, next_row_span)).unwrap();
887                                }
888                                
889                                body.set_attribute("data-resize-start-x", &(start_x + (steps as f64 * col_width)).to_string()).unwrap();
890                                sync_layout_state();
891                            }
892                        }
893                    }
894                }
895            } else if let Some(panel_id) = body.get_attribute("data-resizing-panel-y") {
896                let start_y: f64 = body.get_attribute("data-resize-start-y").unwrap_or("0".into()).parse().unwrap_or(0.0);
897                let current_y = e.client_y() as f64;
898                let delta_y = current_y - start_y;
899                
900                let vh = web_sys::window().unwrap().inner_height().unwrap().as_f64().unwrap();
901                let computed_row = (vh - 400.0) / 12.0;
902                let row_height = computed_row.max(35.0) + 20.0;
903                
904                if delta_y.abs() > row_height * 0.5 {
905                    let steps = (delta_y / row_height).trunc() as i32;
906                    if steps != 0 {
907                        if let Some(panel) = doc.get_element_by_id(&panel_id) {
908                            let current_span: i32 = panel.get_attribute("data-span").unwrap_or("6".into()).parse().unwrap_or(6);
909                            let mut current_row_span: i32 = panel.get_attribute("data-row-span").unwrap_or("4".into()).parse().unwrap_or(4);
910                            
911                            current_row_span += steps;
912                            if current_row_span < 2 { current_row_span = 2; }
913                            
914                            panel.set_attribute("data-row-span", &current_row_span.to_string()).unwrap();
915                            panel.set_attribute("style", &format!("position: relative; grid-column: span {}; grid-row: span {}; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);", current_span, current_row_span)).unwrap();
916                            
917                            body.set_attribute("data-resize-start-y", &(start_y + (steps as f64 * row_height)).to_string()).unwrap();
918                            sync_layout_state();
919                        }
920                    }
921                }
922            }
923        }
924    }) as Box<dyn FnMut(MouseEvent)>);
925    
926    let mouseup = Closure::wrap(Box::new(move |_e: MouseEvent| {
927        let doc = document();
928        if let Some(body) = doc.body() {
929            let _ = body.remove_attribute("data-resizing-panel-x");
930            let _ = body.remove_attribute("data-resizing-panel-y");
931        }
932    }) as Box<dyn FnMut(MouseEvent)>);
933    
934    let window_et: &web_sys::EventTarget = window.as_ref();
935    window_et.add_event_listener_with_callback("mousemove", mousemove.as_ref().unchecked_ref()).unwrap();
936    window_et.add_event_listener_with_callback("mouseup", mouseup.as_ref().unchecked_ref()).unwrap();
937    
938    mousemove.forget();
939    mouseup.forget();
940}
941
942fn setup_resize_observer() {
943    let doc = document();
944    if let Some(body) = doc.body() {
945        if body.get_attribute("data-ro-init").is_some() {
946            return;
947        }
948        body.set_attribute("data-ro-init", "true").unwrap();
949        
950        let callback = Closure::wrap(Box::new(move |entries: js_sys::Array| {
951            if let Some(w) = window() {
952                if let Ok(Some(json_str)) = w.local_storage().unwrap().unwrap().get_item("current_dashboard_data") {
953                    if let Ok(config) = serde_json::from_str::<DashboardConfig>(&json_str) {
954                        for i in 0..entries.length() {
955                            let entry: ResizeObserverEntry = entries.get(i).unchecked_into();
956                            let target = entry.target();
957                            if let Some(id_attr) = target.id().strip_prefix("panel-content-") {
958                                if let Some(panel) = config.panels.iter().find(|p| get_panel_id(p) == id_attr) {
959                                    let new_content_dom = build_panel_content(panel, id_attr).build();
960                                    target.set_inner_html("");
961                                    let _ = target.append_child(&new_content_dom);
962                                }
963                            }
964                        }
965                    }
966                }
967            }
968        }) as Box<dyn FnMut(js_sys::Array)>);
969        
970        let ro = ResizeObserver::new(callback.as_ref().unchecked_ref()).unwrap();
971        
972        let w = window().unwrap();
973        let _ = js_sys::Reflect::set(&w, &JsValue::from_str("global_resize_observer"), &ro);
974        
975        callback.forget();
976    }
977}