Klaster OKD 4.19 (OpenShift) – Projekt monitorujący serwer PVE 9 i klaster OKD morsGUI, aplikacja mors-gui-web

Wstęp

Poniższy opis jest częścią większej całości https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-monitorowanie-serwera-pve-9-i-klastra-okd-projekt-mors/

Wymaga przygotowania klastra OKD 4.19 oraz serwera baz danych PostgreSQL

Prawidłowa kolejność przygotowania projektu MORS na klastrze OKD 4.19

Opis aplikacji mors-gui-web

Aplikacja służy do wyświetlania wykresów na podstawie danych umieszczonych w bazie danych PostgreSQL: dba_mors.
Dane są generowane przez 2 aplikacje

i wczytywane za pomocą API w aplikacji

Kod aplikacji mors-gui-api czyli zawartość index.php ze względu na swoją długość znajduje się na samym końcu tego wpisu.

Struktura aplikacji mors-gui-web

/repo/pakiety/morsgui/1.0.0/mors-gui-web
drwxr-xr-x 4 bastuser bastuser    46 Feb 13 17:49 ..
-rw-r--r-- 1 bastuser bastuser   565 Feb 13 17:51 cm-mors-gui-web.yaml
-rw-r--r-- 1 bastuser bastuser   380 Feb 13 18:00 Dockerfile
-rw-r--r-- 1 bastuser bastuser 23030 Feb 13 18:18 index.php

Przygotowanie configmapy dla aplikacji mors-gui-web

apiVersion: v1
kind: ConfigMap
metadata:
  name: cm-mors-gui-web
  namespace: morsgui-prod
data:
  # UI / wykresy
  PAGE_TITLE: "morsusl-gui • mors-gui-web"
  DEFAULT_RANGE_HOURS: "2"     # domyślne okno czasu po wejściu na stronę
  MAX_RANGE_HOURS: "72"        # twardy limit na zakres (żeby nie zajechać DB i frontu)
  MAX_POINTS: "1500"           # downsampling: max punktów na serię
  REFRESH_SECONDS: "10"        # co ile sekund odświeżać (zapisy są co 5s; 10s jest sensowne)
  DEBUG: "0"                   # 1 pokaże trochę info na dole strony

Przygotowanie Dockerfile na potrzeby zbudowania obrazu aplikacji

FROM php:8.2-cli-alpine
# Runtime libs dla pdo_pgsql (libpq.so.5)
RUN apk add --no-cache libpq
# Build deps tylko na czas kompilacji rozszerzenia
RUN apk add --no-cache --virtual .build-deps postgresql-dev \
  && docker-php-ext-install pdo_pgsql \
  && apk del .build-deps
WORKDIR /app
COPY index.php /app/index.php
EXPOSE 8080
CMD ["php", "-S", "0.0.0.0:8080", "-t", "/app"]

Uruchomienie aplikacji mors-gui-web w projekcie mors-gui-prod

oc new-project morsgui-prod

# Build binarny z Dockerfile
oc new-build --strategy=docker --binary --name=mors-gui-web-build

cd /repo/pakiety/morsgui/1.0.0/mors-gui-web/
oc start-build mors-gui-web-build --from-dir=. --follow

# ConfigMap web (tuning UI)
oc apply -f cm-mors-gui-web.yaml

# Tworzy deployment+svc z imagestream
oc new-app mors-gui-web-build:latest --name=mors-gui-web

# Wstrzyknij ustawienia UI
oc set env deployment/mors-gui-web --from=configmap/cm-mors-gui-web

# Wstrzyknij dane DB z istniejącej configmapy (TO JEST KLUCZOWE)
oc set env deployment/mors-gui-web --from=configmap/cm-mors-gui-api

# Route (żeby wejść w przeglądarce)
oc -n morsgui-prod create route edge mors-gui-web --service=mors-gui-web --port=8080

# Restart
oc rollout restart deployment/mors-gui-web

# Logi
oc logs -f deployment/mors-gui-web

Po zmianach w kodzie aplikacji

cd /repo/pakiety/morsgui/1.0.0/mors-gui-web/
oc start-build mors-gui-web-build --from-dir=. --follow
oc -n morsgui-prod rollout restart deployment/mors-gui-web
oc -n morsgui-prod logs -f deployment/mors-gui-web

Kod aplikacji mors-gui-web

Kod aplikacji znajduje się w 1 pliku index.php

  • Ten plik robi 2 rzeczy:
    1. Serwuje HTML (Bootstrap + Chart.js)
    2. Udostępnia API JSON pod ?api=proxmox i ?api=okd
  • Ma:
    • 2 wykresy (jeden pod drugim)
    • legendę
    • checkboxy pod każdym wykresem (włącz/wyłącz serię)
    • zakres dat (datetime-local) + szybkie “ostatnie X godzin”
    • auto-refresh (domyślnie ON) z ConfigMap
    • downsampling do sensownej liczby punktów (żeby Chart.js nie umarł przy 1 dniu)
<?php
declare(strict_types=1);

/**
 * morsusl-gui: mors-gui-web (single-file)
 * - UI: Bootstrap 5 + Chart.js (CDN)
 * - API: ?api=proxmox / ?api=okd returns JSON rows from PostgreSQL
 */

function env(string $key, string $default = ''): string {
    $v = getenv($key);
    return ($v === false || $v === '') ? $default : $v;
}

function envInt(string $key, int $default): int {
    $v = env($key, (string)$default);
    return is_numeric($v) ? (int)$v : $default;
}

function jsonOut(array $payload, int $code = 200): void {
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store');
    echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function parseDt(?string $s, DateTimeImmutable $fallback): DateTimeImmutable {
    if (!$s) return $fallback;
    try {
        // Accept ISO 8601 (from JS .toISOString()) or any parseable timestamp
        return new DateTimeImmutable($s);
    } catch (Throwable) {
        return $fallback;
    }
}

function clampRange(DateTimeImmutable $start, DateTimeImmutable $end, int $maxHours): array {
    if ($end < $start) {
        [$start, $end] = [$end, $start];
    }
    $maxSeconds = $maxHours * 3600;
    $diff = $end->getTimestamp() - $start->getTimestamp();
    if ($diff > $maxSeconds) {
        $start = $end->sub(new DateInterval('PT' . $maxSeconds . 'S'));
    }
    return [$start, $end];
}

function pdo(): PDO {
    $host = env('DBA_SERVER');
    $port = env('DBA_PORT', '5432');
    $db   = env('DBA_NAME');
    $user = env('DBA_USER');
    $pass = env('DBA_PASSWORD', '');
    $sslmode = env('DBA_SSLMODE', 'prefer'); // disable|allow|prefer|require|verify-ca|verify-full

    if ($host === '' || $db === '' || $user === '') {
        jsonOut([
            'error' => 'Brak wymaganych zmiennych środowiskowych DBA_SERVER/DBA_NAME/DBA_USER (sprawdź cm-mors-gui-api i oc set env).'
        ], 500);
    }

    $dsn = "pgsql:host={$host};port={$port};dbname={$db};sslmode={$sslmode}";

    $pdo = new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);

    // Wymuś sensowne formaty dat/czasu (opcjonalnie)
    $pdo->exec("SET TIME ZONE 'UTC'");

    return $pdo;
}

function downsampleRows(array $rows, int $maxPoints): array {
    $n = count($rows);
    if ($maxPoints <= 0 || $n <= $maxPoints) return $rows;

    $step = (int)ceil($n / $maxPoints);
    $out = [];
    for ($i = 0; $i < $n; $i += $step) {
        $out[] = $rows[$i];
    }
    return $out;
}

function toIsoUtc(string $ts): string {
    // $ts może mieć mikrosekundy; i tak da się to sparsować
    try {
        $dt = new DateTimeImmutable($ts, new DateTimeZone('UTC'));
    } catch (Throwable) {
        $dt = new DateTimeImmutable('now', new DateTimeZone('UTC'));
    }
    return $dt->setTimezone(new DateTimeZone('UTC'))->format(DateTimeInterface::ATOM);
}

function apiProxmox(DateTimeImmutable $start, DateTimeImmutable $end, int $maxPoints): void {
    $pdo = pdo();
    $stmt = $pdo->prepare("
        SELECT created_at, cpu_usage, cpu_temp_c, sys_load, ram_usage_prc, swap_usage_prc,
               gpu_busy_prc, gpu_temp_c, nvme_temp_c
        FROM proxmox
        WHERE created_at BETWEEN :start AND :end
        ORDER BY created_at ASC
    ");
    $stmt->execute([
        ':start' => $start->format('Y-m-d H:i:sP'),
        ':end'   => $end->format('Y-m-d H:i:sP'),
    ]);

    $rows = $stmt->fetchAll();
    $rows = downsampleRows($rows, $maxPoints);

    $out = [];
    foreach ($rows as $r) {
        $load = [null, null, null];
        if (isset($r['sys_load']) && $r['sys_load'] !== null) {
            // oczekiwany format: "5.87,5.92,6.07"
            $parts = array_map('trim', explode(',', (string)$r['sys_load']));
            for ($i = 0; $i < 3; $i++) {
                if (isset($parts[$i]) && $parts[$i] !== '') {
                    $load[$i] = (float)$parts[$i];
                }
            }
        }

        $out[] = [
            't' => toIsoUtc((string)$r['created_at']),
            'cpu_usage'      => isset($r['cpu_usage']) ? (float)$r['cpu_usage'] : null,
            'cpu_temp_c'     => isset($r['cpu_temp_c']) ? (float)$r['cpu_temp_c'] : null,
            'sys_load_1'      => $load[0],
            'sys_load_5'      => $load[1],
            'sys_load_15'     => $load[2],
            'ram_usage_prc'  => isset($r['ram_usage_prc']) ? (float)$r['ram_usage_prc'] : null,
            'swap_usage_prc' => isset($r['swap_usage_prc']) ? (float)$r['swap_usage_prc'] : null,
            'gpu_busy_prc'   => isset($r['gpu_busy_prc']) ? (float)$r['gpu_busy_prc'] : null,
            'gpu_temp_c'     => isset($r['gpu_temp_c']) ? (float)$r['gpu_temp_c'] : null,
            'nvme_temp_c'    => isset($r['nvme_temp_c']) ? (float)$r['nvme_temp_c'] : null,
        ];
    }

    jsonOut([
        'meta' => [
            'table' => 'proxmox',
            'start' => $start->format(DateTimeInterface::ATOM),
            'end'   => $end->format(DateTimeInterface::ATOM),
            'points' => count($out),
        ],
        'rows' => $out,
    ]);
}

function apiOkd(DateTimeImmutable $start, DateTimeImmutable $end, int $maxPoints): void {
    $pdo = pdo();
    $stmt = $pdo->prepare("
        SELECT created_at,
               compute_1_cpu_prc, compute_2_cpu_prc, compute_3_cpu_prc,
               control_1_cpu_prc, control_2_cpu_prc, control_3_cpu_prc,
               compute_1_ram_prc, compute_2_ram_prc, compute_3_ram_prc,
               control_1_ram_prc, control_2_ram_prc, control_3_ram_prc
        FROM okd
        WHERE created_at BETWEEN :start AND :end
        ORDER BY created_at ASC
    ");
    $stmt->execute([
        ':start' => $start->format('Y-m-d H:i:sP'),
        ':end'   => $end->format('Y-m-d H:i:sP'),
    ]);

    $rows = $stmt->fetchAll();
    $rows = downsampleRows($rows, $maxPoints);

    $out = [];
    foreach ($rows as $r) {
        $out[] = [
            't' => toIsoUtc((string)$r['created_at']),
            'compute_1_cpu_prc' => isset($r['compute_1_cpu_prc']) ? (float)$r['compute_1_cpu_prc'] : null,
            'compute_2_cpu_prc' => isset($r['compute_2_cpu_prc']) ? (float)$r['compute_2_cpu_prc'] : null,
            'compute_3_cpu_prc' => isset($r['compute_3_cpu_prc']) ? (float)$r['compute_3_cpu_prc'] : null,
            'control_1_cpu_prc' => isset($r['control_1_cpu_prc']) ? (float)$r['control_1_cpu_prc'] : null,
            'control_2_cpu_prc' => isset($r['control_2_cpu_prc']) ? (float)$r['control_2_cpu_prc'] : null,
            'control_3_cpu_prc' => isset($r['control_3_cpu_prc']) ? (float)$r['control_3_cpu_prc'] : null,
            'compute_1_ram_prc' => isset($r['compute_1_ram_prc']) ? (float)$r['compute_1_ram_prc'] : null,
            'compute_2_ram_prc' => isset($r['compute_2_ram_prc']) ? (float)$r['compute_2_ram_prc'] : null,
            'compute_3_ram_prc' => isset($r['compute_3_ram_prc']) ? (float)$r['compute_3_ram_prc'] : null,
            'control_1_ram_prc' => isset($r['control_1_ram_prc']) ? (float)$r['control_1_ram_prc'] : null,
            'control_2_ram_prc' => isset($r['control_2_ram_prc']) ? (float)$r['control_2_ram_prc'] : null,
            'control_3_ram_prc' => isset($r['control_3_ram_prc']) ? (float)$r['control_3_ram_prc'] : null,
        ];
    }

    jsonOut([
        'meta' => [
            'table' => 'okd',
            'start' => $start->format(DateTimeInterface::ATOM),
            'end'   => $end->format(DateTimeInterface::ATOM),
            'points' => count($out),
        ],
        'rows' => $out,
    ]);
}

/* ---------------------------
   API mode
----------------------------*/
if (isset($_GET['api'])) {
    $defaultHours = envInt('DEFAULT_RANGE_HOURS', 2);
    $maxRangeHours = envInt('MAX_RANGE_HOURS', 72);
    $maxPoints = envInt('MAX_POINTS', 1500);

    $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
    $fallbackStart = $now->sub(new DateInterval('PT' . max(1, $defaultHours) . 'H'));

    $start = parseDt($_GET['start'] ?? null, $fallbackStart);
    $end   = parseDt($_GET['end'] ?? null, $now);

    [$start, $end] = clampRange($start, $end, $maxRangeHours);

    $reqMaxPoints = isset($_GET['max_points']) && is_numeric($_GET['max_points']) ? (int)$_GET['max_points'] : $maxPoints;
    $reqMaxPoints = max(200, min($reqMaxPoints, $maxPoints)); // dolny limit, górny limit

    $api = (string)$_GET['api'];
    if ($api === 'proxmox') apiProxmox($start, $end, $reqMaxPoints);
    if ($api === 'okd')     apiOkd($start, $end, $reqMaxPoints);

    jsonOut(['error' => 'Nieznane api. Użyj: ?api=proxmox albo ?api=okd'], 400);
}

/* ---------------------------
   UI mode (HTML)
----------------------------*/
$title = env('PAGE_TITLE', 'morsusl-gui • mors-gui-web');
$defaultHours = envInt('DEFAULT_RANGE_HOURS', 2);
$refreshSec   = envInt('REFRESH_SECONDS', 10);
$maxPoints    = envInt('MAX_POINTS', 1500);
$debug        = envInt('DEBUG', 0);
?>
<!doctype html>
<html lang="pl">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></title>

  <!-- Bootstrap 5 -->
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">

  <!-- Chart.js + time adapter -->
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-adapter-date-fns.bundle.min.js"></script>

  <style>
    body { background: #0b1220; color: #e8eefc; }
    .card { background: #0f1a2e; border: 1px solid rgba(255,255,255,.08); }
    .muted { color: rgba(232,238,252,.7); }
    canvas { width: 100% !important; height: 420px !important; }
    .checkbox-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: .35rem .75rem; }
    .small-note { font-size: .9rem; }
    .badge-soft { background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.08); }
    .form-control, .form-select { background: #0b1220; color: #e8eefc; border-color: rgba(255,255,255,.16); }
    .form-control:focus, .form-select:focus { box-shadow: none; border-color: rgba(255,255,255,.35); }
  </style>
</head>
<body>
<div class="container-fluid px-3 px-lg-4 py-3 py-lg-4">
  <div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-3">
    <div>
      <h1 class="h4 mb-1"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></h1>
      <div class="muted small-note">
        2 wykresy: <span class="badge badge-soft">proxmox</span> i <span class="badge badge-soft">okd</span> •
        domyślnie ostatnie <?= (int)$defaultHours ?>h • auto-refresh co <?= (int)$refreshSec ?>s • max <?= (int)$maxPoints ?> punktów na serię
      </div>
    </div>

    <div class="d-flex flex-wrap gap-2 align-items-end">
      <div>
        <label class="form-label mb-1 muted">Szybki zakres</label>
        <select id="quickRange" class="form-select form-select-sm">
          <option value="1">Ostatnia 1h</option>
          <option value="2" selected>Ostatnie 2h</option>
          <option value="6">Ostatnie 6h</option>
          <option value="12">Ostatnie 12h</option>
          <option value="24">Ostatnie 24h</option>
          <option value="48">Ostatnie 48h</option>
          <option value="72">Ostatnie 72h</option>
        </select>
      </div>

      <div>
        <label class="form-label mb-1 muted">Od</label>
        <input id="dtFrom" type="datetime-local" class="form-control form-control-sm">
      </div>
      <div>
        <label class="form-label mb-1 muted">Do</label>
        <input id="dtTo" type="datetime-local" class="form-control form-control-sm">
      </div>

      <div class="form-check mb-1">
        <input class="form-check-input" type="checkbox" value="" id="autoRefresh" checked>
        <label class="form-check-label muted" for="autoRefresh">auto-refresh</label>
      </div>

      <button id="btnApply" class="btn btn-sm btn-primary">Zastosuj</button>
      <button id="btnNow" class="btn btn-sm btn-outline-light">Teraz</button>
    </div>
  </div>

  <!-- Proxmox -->
  <div class="card rounded-4 shadow-sm mb-3">
    <div class="card-body">
      <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
        <div>
          <div class="h6 mb-0">Proxmox</div>
          <div class="muted small-note" id="metaProxmox">…</div>
        </div>
        <div class="muted small-note" id="statusProxmox"></div>
      </div>

      <canvas id="chartProxmox"></canvas>

      <hr class="border-light border-opacity-10 my-3">
      <div class="muted small-note mb-2">Serie (checkbox = pokaż/ukryj):</div>
      <div id="checksProxmox" class="checkbox-grid"></div>
    </div>
  </div>

  <!-- OKD -->
  <div class="card rounded-4 shadow-sm">
    <div class="card-body">
      <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
        <div>
          <div class="h6 mb-0">OKD</div>
          <div class="muted small-note" id="metaOkd">…</div>
        </div>
        <div class="muted small-note" id="statusOkd"></div>
      </div>

      <canvas id="chartOkd"></canvas>

      <hr class="border-light border-opacity-10 my-3">
      <div class="muted small-note mb-2">Serie (checkbox = pokaż/ukryj):</div>
      <div id="checksOkd" class="checkbox-grid"></div>
    </div>
  </div>

  <?php if ($debug): ?>
  <div class="mt-3 muted small-note">
    DEBUG=1 • DBA_SERVER=<?= htmlspecialchars(env('DBA_SERVER',''), ENT_QUOTES, 'UTF-8') ?> • DBA_NAME=<?= htmlspecialchars(env('DBA_NAME',''), ENT_QUOTES, 'UTF-8') ?>
  </div>
  <?php endif; ?>
</div>

<script>
(() => {
  // ---------- Konfiguracja z env (wstrzyknięta przez PHP) ----------
  const DEFAULT_HOURS = <?= (int)$defaultHours ?>;
  const REFRESH_MS = <?= (int)$refreshSec ?> * 1000;
  const MAX_POINTS = <?= (int)$maxPoints ?>;

  // Stała paleta kolorów (każda seria ma inny kolor)
  const COLORS = [
    '#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f',
    '#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab',
    '#7f7f7f','#bcbd22','#17becf','#aec7e8','#ffbb78'
  ];

  // Pola do proxmox (sys_load rozbite na 3)
  const proxmoxFields = [
    { key:'cpu_usage',      label:'CPU %' },
    { key:'cpu_temp_c',     label:'CPU temp °C' },
    { key:'sys_load_1',     label:'Load 1m' },
    { key:'sys_load_5',     label:'Load 5m' },
    { key:'sys_load_15',    label:'Load 15m' },
    { key:'ram_usage_prc',  label:'RAM %' },
    { key:'swap_usage_prc', label:'SWAP %' },
    { key:'gpu_busy_prc',   label:'GPU busy %' },
    { key:'gpu_temp_c',     label:'GPU temp °C' },
    { key:'nvme_temp_c',    label:'NVMe temp °C' }
  ];

  // Pola do okd
  const okdFields = [
    { key:'compute_1_cpu_prc', label:'compute-1 CPU %' },
    { key:'compute_2_cpu_prc', label:'compute-2 CPU %' },
    { key:'compute_3_cpu_prc', label:'compute-3 CPU %' },
    { key:'control_1_cpu_prc', label:'control-1 CPU %' },
    { key:'control_2_cpu_prc', label:'control-2 CPU %' },
    { key:'control_3_cpu_prc', label:'control-3 CPU %' },
    { key:'compute_1_ram_prc', label:'compute-1 RAM %' },
    { key:'compute_2_ram_prc', label:'compute-2 RAM %' },
    { key:'compute_3_ram_prc', label:'compute-3 RAM %' },
    { key:'control_1_ram_prc', label:'control-1 RAM %' },
    { key:'control_2_ram_prc', label:'control-2 RAM %' },
    { key:'control_3_ram_prc', label:'control-3 RAM %' }
  ];

  // ---------- Helpers ----------
  function pad2(n){ return String(n).padStart(2,'0'); }
  function toDatetimeLocalValue(d){
    // datetime-local bez strefy: YYYY-MM-DDTHH:mm
    return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
  }

  function parseDatetimeLocal(val){
    // val: 'YYYY-MM-DDTHH:mm' -> Date w local tz
    const d = new Date(val);
    return isNaN(d.getTime()) ? null : d;
  }

  function buildDatasets(fields){
    return fields.map((f, idx) => ({
      label: f.label,
      parsing: false,
      data: [],
      borderColor: COLORS[idx % COLORS.length],
      backgroundColor: COLORS[idx % COLORS.length],
      borderWidth: 2,
      pointRadius: 0,
      tension: 0.25,
    }));
  }

  function makeChart(canvasId, fields){
    const ctx = document.getElementById(canvasId);
    return new Chart(ctx, {
      type: 'line',
      data: { datasets: buildDatasets(fields) },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: false,
        interaction: { mode: 'index', intersect: false },
        plugins: {
          legend: { display: true, labels: { color: '#e8eefc' } },
          tooltip: { enabled: true }
        },
        scales: {
          x: {
            type: 'time',
            time: { tooltipFormat: 'yyyy-MM-dd HH:mm:ss' },
            ticks: { color: 'rgba(232,238,252,.75)' },
            grid: { color: 'rgba(255,255,255,.06)' }
          },
          y: {
            ticks: { color: 'rgba(232,238,252,.75)' },
            grid: { color: 'rgba(255,255,255,.06)' }
          }
        }
      }
    });
  }

  function buildCheckboxes(containerId, chart){
    const container = document.getElementById(containerId);
    container.innerHTML = '';
    chart.data.datasets.forEach((ds, i) => {
      const id = `${containerId}_${i}`;
      const wrap = document.createElement('div');
      wrap.className = 'form-check';

      const input = document.createElement('input');
      input.className = 'form-check-input';
      input.type = 'checkbox';
      input.id = id;
      input.checked = chart.isDatasetVisible(i);

      input.addEventListener('change', () => {
        chart.setDatasetVisibility(i, input.checked);
        chart.update('none');
      });

      const label = document.createElement('label');
      label.className = 'form-check-label muted';
      label.htmlFor = id;
      label.textContent = ds.label;

      // mini kolorowy "znacznik"
      const dot = document.createElement('span');
      dot.style.display = 'inline-block';
      dot.style.width = '10px';
      dot.style.height = '10px';
      dot.style.borderRadius = '50%';
      dot.style.marginRight = '8px';
      dot.style.background = ds.borderColor;

      label.prepend(dot);

      wrap.appendChild(input);
      wrap.appendChild(label);
      container.appendChild(wrap);
    });
  }

  function setStatus(id, text){
    document.getElementById(id).textContent = text || '';
  }

  function setMeta(id, meta){
    if (!meta) return;
    document.getElementById(id).textContent =
      `zakres: ${meta.start} → ${meta.end} • punktów: ${meta.points}`;
  }

  async function fetchRows(api, startISO, endISO){
    const url = `?api=${encodeURIComponent(api)}&start=${encodeURIComponent(startISO)}&end=${encodeURIComponent(endISO)}&max_points=${MAX_POINTS}`;
    const res = await fetch(url, { cache: 'no-store' });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  }

function applyDataToChart(chart, fields, rows){
  const datasets = chart.data.datasets;

  for (let i = 0; i < fields.length; i++){
    const key = fields[i].key;

    const pts = [];
    for (const r of rows) {
      const x = Date.parse(r.t);              // <-- klucz: timestamp w ms
      const raw = r[key];

      // y: null zostawiamy jako "dziura" (przerwa w linii), ale x musi być poprawny
      const y = (raw === null || raw === undefined) ? null : Number(raw);

      if (!Number.isFinite(x)) continue;      // jeśli data nieparsowalna, pomiń punkt
      if (y !== null && Number.isNaN(y)) continue; // jeśli y = NaN, też pomiń

      pts.push({ x, y });
    }

    datasets[i].data = pts;
  }

  chart.update('none');
}

  // ---------- UI controls ----------
  const quickRange = document.getElementById('quickRange');
  const dtFrom = document.getElementById('dtFrom');
  const dtTo = document.getElementById('dtTo');
  const btnApply = document.getElementById('btnApply');
  const btnNow = document.getElementById('btnNow');
  const autoRefresh = document.getElementById('autoRefresh');

  function setQuickRange(hours){
    const now = new Date();
    const from = new Date(now.getTime() - hours*3600*1000);
    dtFrom.value = toDatetimeLocalValue(from);
    dtTo.value = toDatetimeLocalValue(now);
  }

  // ustaw domyślny range na podstawie env
  quickRange.value = String(DEFAULT_HOURS);
  setQuickRange(DEFAULT_HOURS);

  // ---------- Charts ----------
  const chartProxmox = makeChart('chartProxmox', proxmoxFields);
  const chartOkd = makeChart('chartOkd', okdFields);

  buildCheckboxes('checksProxmox', chartProxmox);
  buildCheckboxes('checksOkd', chartOkd);

  // ---------- Refresh logic ----------
  let timer = null;

  async function refreshAll(){
    const fromD = parseDatetimeLocal(dtFrom.value);
    const toD = parseDatetimeLocal(dtTo.value);
    if (!fromD || !toD) return;

    const startISO = fromD.toISOString();
    const endISO = toD.toISOString();

    // Proxmox
    try {
      setStatus('statusProxmox', 'Ładowanie…');
      const data = await fetchRows('proxmox', startISO, endISO);
      applyDataToChart(chartProxmox, proxmoxFields, data.rows || []);
      setMeta('metaProxmox', data.meta);
      setStatus('statusProxmox', '');
    } catch (e) {
      setStatus('statusProxmox', 'Błąd pobierania danych (sprawdź logi).');
      console.error(e);
    }

    // OKD
    try {
      setStatus('statusOkd', 'Ładowanie…');
      const data = await fetchRows('okd', startISO, endISO);
      applyDataToChart(chartOkd, okdFields, data.rows || []);
      setMeta('metaOkd', data.meta);
      setStatus('statusOkd', '');
    } catch (e) {
      setStatus('statusOkd', 'Błąd pobierania danych (sprawdź logi).');
      console.error(e);
    }
  }

  function startTimer(){
    if (timer) clearInterval(timer);
    timer = setInterval(() => {
      if (autoRefresh.checked) {
        // przesuwamy "Do" na teraz, zachowując długość okna
        const fromD = parseDatetimeLocal(dtFrom.value);
        const toD = parseDatetimeLocal(dtTo.value);
        if (fromD && toD) {
          const windowMs = toD.getTime() - fromD.getTime();
          const now = new Date();
          const newFrom = new Date(now.getTime() - windowMs);
          dtFrom.value = toDatetimeLocalValue(newFrom);
          dtTo.value = toDatetimeLocalValue(now);
        }
        refreshAll();
      }
    }, REFRESH_MS);
  }

  // events
  quickRange.addEventListener('change', () => setQuickRange(Number(quickRange.value)));
  btnApply.addEventListener('click', () => refreshAll());
  btnNow.addEventListener('click', () => {
    setQuickRange(Number(quickRange.value || DEFAULT_HOURS));
    refreshAll();
  });

  // first load + timer
  refreshAll();
  startTimer();
})();
</script>
</body>
</html>