User Tools

Site Tools


start:utils:cockpit:p1:cpu:index

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;");
}
This website uses cookies. By using the website, you agree with storing cookies on your computer. Also you acknowledge that you have read and understand our Privacy Policy. If you do not agree leave the website.More information about cookies
start/utils/cockpit/p1/cpu/index.txt · Last modified: by admin_wiki