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
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-na-proxmox-9-opis-instalacjii-konfiguracji/
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-na-proxmox-9-opis-postgresql-baza-danych-dla-aplikacji-openshift/
Prawidłowa kolejność przygotowania projektu MORS na klastrze OKD 4.19
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-projekt-monitorujacy-serwer-pve-9-i-klaster-okd-morsusl-aplikacja-mors-usl-proxmox/
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-projekt-monitorujacy-serwer-pve-9-i-klaster-okd-morsusl-aplikacja-mors-usl-okd/
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-projekt-monitorujacy-serwer-pve-9-i-klaster-okd-morsgui-aplikacja-mors-gui-api/
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-projekt-monitorujacy-serwer-pve-9-i-klaster-okd-projekt-morsgui-aplikacja-mors-gui-web/
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
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-projekt-monitorujacy-serwer-pve-9-i-klaster-okd-morsusl-aplikacja-mors-usl-proxmox/
- https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-projekt-monitorujacy-serwer-pve-9-i-klaster-okd-morsusl-aplikacja-mors-usl-okd/
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:
- Serwuje HTML (Bootstrap + Chart.js)
- Udostępnia API JSON pod
?api=proxmoxi?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>

