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(¤t_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(¤t_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 let current_classes = panel.class_name();
610 if current_classes.contains("panel-alert") {
611 panel.set_class_name(¤t_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(¤t_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(¤t_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", ¤t_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", ¤t_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}