Dashboard de l'application
# WebIndex.py from datetime import datetime from zoneinfo import ZoneInfo from collections import defaultdict import PersistentData TZ = ZoneInfo("Europe/Zurich") UTC = ZoneInfo("UTC") def _utc_to_local(ts_str): """Converts a UTC timestamp string to Europe/Zurich ISO string for Chart.js.""" try: dt = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) return dt.astimezone(TZ).strftime("%Y-%m-%dT%H:%M:%S") except Exception: try: dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC) return dt.astimezone(TZ).strftime("%Y-%m-%dT%H:%M:%S") except Exception: return ts_str def _utc_sql_to_local(ts_str): """Converts a UTC SQLite timestamp string to Europe/Zurich ISO string.""" try: dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC) return dt.astimezone(TZ).strftime("%Y-%m-%dT%H:%M:%S") except Exception: return ts_str def on_tickets_updated(ui, count): """Called after each successful ticket update — notifies the dashboard.""" now = datetime.now(ZoneInfo("Europe/Zurich")).strftime("%H:%M:%S") ui.send_message("tickets_updated", {"count": count, "time": now}) def init_dashboard_interface(ui): """ Registers the bridge between index.html (dashboard) and Python logic. """ def handle_get_sensor_data(_, data): period = data.get("period", "1d") period_map = { "1d": ("-1d", "5m"), "1w": ("-7d", "15m"), "1m": ("-30d", "1h"), } start_from, aggr_window = period_map.get(period, ("-1d", "5m")) try: co2 = PersistentData.tsdb.read_samples("co2", start_from=start_from, aggr_window=aggr_window, aggr_func="mean") temp = PersistentData.tsdb.read_samples("temperature", start_from=start_from, aggr_window=aggr_window, aggr_func="mean") humi = PersistentData.tsdb.read_samples("humidity", start_from=start_from, aggr_window=aggr_window, aggr_func="mean") def to_list(samples): return [{"ts": s[1], "v": round(s[2], 2)} for s in (samples or [])] ui.send_message("sensor_data", { "period": period, "co2": to_list(co2), "temperature": to_list(temp), "humidity": to_list(humi), }) except Exception as e: print(f"ERROR fetching sensor data: {e}") ui.send_message("sensor_data", {"period": period, "co2": [], "temperature": [], "humidity": []}) def handle_get_ticket_filters(_, __): """Returns available filter options: selected orgs and types with selected priorities.""" orgs = PersistentData.db.read("organizations", condition="selected = 1 AND enabled = 1") or [] org_list = [{"id": str(o["id"]), "name": o["name"]} for o in sorted(orgs, key=lambda x: x["name"])] prio_rows = PersistentData.db.read("objects", condition="type = 'TICKET_PRIORITY' AND selected = 1") or [] types = sorted(set(p["subType"] for p in prio_rows if p.get("subType"))) ui.send_message("ticket_filters", {"orgs": org_list, "types": types}) def handle_get_ticket_history(_, data): """Returns TICKETS_TOTAL and TICKETS_FILTERED aggregated by timestamp.""" period = data.get("period", "1d") org_ids = set(str(x) for x in data.get("org_ids", [])) types = set(data.get("types", [])) period_hours = {"1d": 24, "1w": 168, "1m": 720} hours = period_hours.get(period, 24) try: rows = PersistentData.db.execute_sql(f""" SELECT timestamp, kind, value FROM history WHERE (kind LIKE 'TICKETS_TOTAL_%' OR kind LIKE 'TICKETS_FILTERED_%') AND timestamp >= datetime('now', '-{hours} hours') ORDER BY timestamp ASC """) or [] total_by_ts = defaultdict(float) filtered_by_ts = defaultdict(float) for row in rows: ts = row["timestamp"] kind = row["kind"] val = row["value"] parts = kind.split("_") if len(parts) < 4: continue org_id = parts[2] sub_type = parts[3] if org_ids and org_id not in org_ids: continue if types and sub_type not in types: continue if parts[1] == "TOTAL": total_by_ts[ts] += val elif parts[1] == "FILTERED": filtered_by_ts[ts] += val all_ts = sorted(total_by_ts.keys()) ui.send_message("ticket_history", { "period": period, "labels": [_utc_sql_to_local(ts) for ts in all_ts], "total": [round(total_by_ts.get(ts, 0), 2) for ts in all_ts], "filtered": [round(filtered_by_ts.get(ts, 0), 2) for ts in all_ts], }) except Exception as e: print(f"ERROR fetching ticket history: {e}") ui.send_message("ticket_history", {"period": period, "labels": [], "total": [], "filtered": []}) ui.on_message("get_sensor_data", handle_get_sensor_data) ui.on_message("get_ticket_filters", handle_get_ticket_filters) ui.on_message("get_ticket_history", handle_get_ticket_history)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>P1 Notifications 4 Cockpit ITSM</title> <link rel="stylesheet" type="text/css" href="style.css"> <link rel="icon" type="image/x-icon" href="img/favicon.ico"> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script> </head> <body> <div class="dashboard-outer"> <header class="dashboard-header"> <h1>P1 Notifications 4 Cockpit ITSM</h1> </header> <!-- Ticket chart --> <div class="chart-block" style="margin-bottom: 1rem;"> <div class="chart-title">Tickets <span class="chart-unit">count</span></div> <div class="chart-wrap" style="height:180px;"><canvas id="chartTickets"></canvas></div> </div> <!-- Sensor charts row --> <div class="sensor-row"> <div class="chart-block"> <div class="chart-title">CO₂ <span class="chart-unit">ppm</span></div> <div class="chart-wrap"><canvas id="chartCo2"></canvas></div> </div> <div class="chart-block"> <div class="chart-title">Température <span class="chart-unit">°C</span></div> <div class="chart-wrap"><canvas id="chartTemp"></canvas></div> </div> <div class="chart-block"> <div class="chart-title">Humidité <span class="chart-unit">%</span></div> <div class="chart-wrap"><canvas id="chartHumi"></canvas></div> </div> </div> <!-- Bottom toolbar --> <div class="dashboard-toolbar" style="margin-top: 1rem;"> <div class="range-group"> <button class="btn-range active" data-period="1d">24h</button> <button class="btn-range" data-period="1w">7j</button> <button class="btn-range" data-period="1m">30j</button> </div> <div id="statusMessage" class="status-message" style="text-align:center; flex:1;"></div> <a href="Tickets.html" class="btn-secondary btn-icon-only" title="Open tickets"> <img src="img/ticket.svg" alt="Tickets" class="btn-icon"> </a> <a href="Config.html" class="btn-secondary btn-config"> <img src="img/gear-filled.svg" alt="" class="btn-icon">Configuration </a> </div> </div> <script src="libs/socket.io.min.js"></script> <script src="app_index.js"></script> </body> </html>
// app_index.js const socket = io(); let currentPeriod = "1d"; let chartCo2, chartTemp, chartHumi, chartTickets; // Filter state — all selected by default let filterOrgs = { items: [], selected: new Set() }; let filterTypes = { items: [], selected: new Set() }; // ── Colors ───────────────────────────────────────────────────────────────────── const COLOR_CO2 = "rgb(197, 225, 167)"; const COLOR_TEMP = "rgb(242, 79, 41)"; const COLOR_HUMI = "rgb(103, 182, 244)"; const COLOR_TOTAL = "rgb(254, 223, 135)"; /* MEDIUM yellow — total tickets */ const COLOR_FILTERED = "rgb(242, 79, 41)"; /* VERY_HIGH red — filtered tickets */ // ── Crosshair plugin ─────────────────────────────────────────────────────────── const sensorCharts = () => [chartCo2, chartTemp, chartHumi].filter(Boolean); const crosshairPlugin = { id: "crosshairSync", afterDraw(chart) { if (chart._crosshairX == null) return; const ctx = chart.ctx, area = chart.chartArea; ctx.save(); ctx.beginPath(); ctx.moveTo(chart._crosshairX, area.top); ctx.lineTo(chart._crosshairX, area.bottom); ctx.lineWidth = 1; ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.stroke(); ctx.restore(); } }; Chart.register(crosshairPlugin); function syncCrosshair(sourceChart, x) { sensorCharts().forEach(c => { if (c === sourceChart) return; const src = sourceChart.scales.x, tgt = c.scales.x; if (!src || !tgt) return; c._crosshairX = tgt.left + ((x - src.left) / src.width) * tgt.width; c.draw(); }); } function clearCrosshair() { sensorCharts().forEach(c => { c._crosshairX = null; c.draw(); }); } function attachCrosshairEvents(canvas, getChart) { canvas.addEventListener("mousemove", e => { const chart = getChart(); if (!chart) return; const x = e.clientX - canvas.getBoundingClientRect().left; chart._crosshairX = x; chart.draw(); syncCrosshair(chart, x); }); canvas.addEventListener("mouseleave", clearCrosshair); } // ── Chart factory ────────────────────────────────────────────────────────────── function makeSensorChart(canvasId, label, color) { return new Chart(document.getElementById(canvasId).getContext("2d"), { type: "line", data: { datasets: [{ label, data: [], borderColor: color, backgroundColor: color.replace("rgb(","rgba(").replace(")",", 0.15)"), borderWidth: 1.5, pointRadius: 0, tension: 0.3, fill: true }]}, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { display: false } }, scales: { x: { type: "time", time: { tooltipFormat: "dd.MM HH:mm", displayFormats: { hour: "HH:mm", day: "dd.MM" } }, ticks: { color: "#666", maxTicksLimit: 5 }, grid: { color: "#2a2a2a" } }, y: { ticks: { color: "#666" }, grid: { color: "#2a2a2a" } } } } }); } function makeAreaChart() { return new Chart(document.getElementById("chartTickets").getContext("2d"), { type: "line", data: { datasets: [ { label: "Total", data: [], borderColor: COLOR_TOTAL, backgroundColor: "rgba(254,223,135,0.3)", borderWidth: 1.5, pointRadius: 0, stepped: "before", fill: false, spanGaps: true, order: 2 }, { label: "Filtered", data: [], borderColor: COLOR_FILTERED, backgroundColor: "rgba(242,79,41,0.5)", borderWidth: 1.5, pointRadius: 0, stepped: "before", fill: false, spanGaps: true, order: 1 }, ]}, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { position: "bottom", labels: { color: "#b0b0b0", boxWidth: 10, font: { size: 11 }, padding: 8 } } }, scales: { x: { type: "time", time: { tooltipFormat: "dd.MM HH:mm", displayFormats: { hour: "HH:mm", day: "dd.MM" } }, ticks: { color: "#666", maxTicksLimit: 8 }, grid: { color: "#2a2a2a" } }, y: { ticks: { color: "#666", stepSize: 1, callback: v => Number.isInteger(v) ? v : null }, grid: { color: "#2a2a2a" } } } } }); } function initCharts() { chartCo2 = makeSensorChart("chartCo2", "CO₂ (ppm)", COLOR_CO2); chartTemp = makeSensorChart("chartTemp", "Temp (°C)", COLOR_TEMP); chartHumi = makeSensorChart("chartHumi", "Humi (%)", COLOR_HUMI); chartTickets = makeAreaChart(); attachCrosshairEvents(document.getElementById("chartCo2"), () => chartCo2); attachCrosshairEvents(document.getElementById("chartTemp"), () => chartTemp); attachCrosshairEvents(document.getElementById("chartHumi"), () => chartHumi); } // ── Multi-select dropdown ────────────────────────────────────────────────────── function toggleDropdown(id) { const panel = document.getElementById(`${id}-panel`); const isOpen = panel.classList.contains("open"); // Close all document.querySelectorAll(".ms-panel.open").forEach(p => p.classList.remove("open")); if (!isOpen) panel.classList.add("open"); } // Close dropdowns when clicking outside document.addEventListener("click", e => { if (!e.target.closest(".ms-dropdown")) { document.querySelectorAll(".ms-panel.open").forEach(p => p.classList.remove("open")); } }); function buildDropdown(id, items, filter) { const list = document.getElementById(`${id}-list`); list.innerHTML = items.map(item => ` <label class="ms-item"> <input type="checkbox" value="${item.value}" ${filter.selected.has(item.value) ? "checked" : ""} onchange="updateSelection('${id}', this)"> ${escapeHtml(item.label)} </label> `).join(""); updateLabel(id, filter); } function updateSelection(id, checkbox) { const filter = id === "ddOrgs" ? filterOrgs : filterTypes; if (checkbox.checked) filter.selected.add(checkbox.value); else filter.selected.delete(checkbox.value); updateLabel(id, filter); } function updateLabel(id, filter) { const total = filter.items.length; const selected = filter.selected.size; const label = id === "ddOrgs" ? "Organizations" : "Types"; document.getElementById(`${id}-label`).textContent = selected === total ? label : `${label} (${selected}/${total})`; } function filterItems(id, query) { const q = query.toLowerCase(); const items = document.querySelectorAll(`#${id}-list .ms-item`); items.forEach(item => { item.style.display = item.textContent.toLowerCase().includes(q) ? "" : "none"; }); } function selectAllItems(id) { const filter = id === "ddOrgs" ? filterOrgs : filterTypes; filter.items.forEach(item => filter.selected.add(item.value)); buildDropdown(id, filter.items, filter); } function selectNoneItems(id) { const filter = id === "ddOrgs" ? filterOrgs : filterTypes; filter.selected.clear(); buildDropdown(id, filter.items, filter); } function applyFilter(id) { document.getElementById(`${id}-panel`).classList.remove("open"); loadTicketHistory(); } // ── Data loading ─────────────────────────────────────────────────────────────── function loadData(period) { currentPeriod = period; socket.emit("get_sensor_data", { period }); loadTicketHistory(); } function loadTicketHistory() { socket.emit("get_ticket_history", { period: currentPeriod, org_ids: [...filterOrgs.selected], types: [...filterTypes.selected], }); } // ── Socket events ────────────────────────────────────────────────────────────── socket.on("connect", () => { initCharts(); socket.emit("get_ticket_filters", {}); loadData(currentPeriod); }); socket.on("connect_error", () => setStatus("Connection error. Please reload.", false)); socket.on("ticket_filters", function(data) { // Build org filter filterOrgs.items = (data.orgs || []).map(o => ({ value: o.id, label: o.name })); filterOrgs.selected = new Set(filterOrgs.items.map(i => i.value)); buildDropdown("ddOrgs", filterOrgs.items, filterOrgs); // Build type filter filterTypes.items = (data.types || []).map(t => ({ value: t, label: t })); filterTypes.selected = new Set(filterTypes.items.map(i => i.value)); buildDropdown("ddTypes", filterTypes.items, filterTypes); }); socket.on("sensor_data", function(data) { const toXY = arr => (arr || []).map(r => ({ x: r.ts, y: r.v })); chartCo2.data.datasets[0].data = toXY(data.co2); chartTemp.data.datasets[0].data = toXY(data.temperature); chartHumi.data.datasets[0].data = toXY(data.humidity); chartCo2.update(); chartTemp.update(); chartHumi.update(); }); socket.on("ticket_history", function(data) { const labels = data.labels || []; const total = data.total || []; const filtered = data.filtered || []; chartTickets.data.datasets[0].data = labels.map((ts, i) => ({ x: ts, y: total[i] })); chartTickets.data.datasets[1].data = labels.map((ts, i) => ({ x: ts, y: filtered[i] })); // Clamp x axis to last known point — prevents Chart.js from extending to 0 if (labels.length > 0) { chartTickets.options.scales.x.max = labels[labels.length - 1]; } chartTickets.update(); }); socket.on("tickets_updated", (data) => { setStatus(`Tickets updated at ${data.time} (${data.count} tickets)`, null); loadTicketHistory(); }); // ── Range buttons ────────────────────────────────────────────────────────────── document.querySelectorAll(".btn-range").forEach(btn => { btn.addEventListener("click", () => { document.querySelectorAll(".btn-range").forEach(b => b.classList.remove("active")); btn.classList.add("active"); loadData(btn.dataset.period); }); }); // ── Helpers ──────────────────────────────────────────────────────────────────── function setStatus(message, success) { const el = document.getElementById("statusMessage"); el.innerText = message; if (success === true) el.style.color = "#4db6ac"; else if (success === false) el.style.color = "#e57373"; else el.style.color = "#bb86fc"; } function escapeHtml(str) { return String(str).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }