Table of Contents

Index

Dashboard de l'application

WebIndex.py

WebIndex.py
# 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)

index.html

index.html
<!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

app_index.js
// 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}