Files
als-alert-screen/als_alert_screen.html
T
sases 199e71327f Przejście na kanał zbiorczy LuPort + naprawa reconnectu, watchdog, widoczny offline
PRZYCZYNA ZNIKAJĄCYCH ALARMÓW ZNALEZIONA — i nie była nią liczba połączeń.
Commit 7cf0634 ("hidden status-bar & machine.status") zakomentował element
#status-bar w HTML, ale refreshStatus() nadal po niego sięgał. TypeError na
null przerywał handler ws.onclose PRZED zaplanowaniem reconnectu — każda
maszyna po pierwszym zerwaniu połączenia milkła NA ZAWSZE. Restart z crona
co 2 h maskował problem, zaczynając od zera.

Czerwcowe poprawki na kiosku (timery świeżości per-maszyna, ubijanie "zombie"
połączeń, jitter) szły w dobrą stronę, ale nieświadomie POGORSZYŁY objaw:
watchdog celowo zamykał ciche połączenie, a każde zamknięcie przechodziło
przez ten sam wadliwy onclose — własna obrona uśmiercała maszynę na stałe.
Ich sedno jest zachowane w tej wersji (wykrywanie ciszy, jitter, nakładkowy
iframe z "iframe optimalization").

NOWA ARCHITEKTURA (pod nowy system LuPort):
- JEDNO połączenie na kanał zbiorczy /ws/als-data/ zamiast 35 per-maszyna;
  serwer wysyła migawkę stanu wszystkich maszyn zaraz po połączeniu (dodane
  w LuPort równolegle), więc trwający alarm wraca na ekran natychmiast po
  reconnexie
- token ApiUser w adresie (nowy system wymaga uwierzytelnienia WS); konto
  bot-als-alert-screen bez żadnych uprawnień
- reconnect z wykładniczym odstępem (1->30 s) i jitterem; odstęp wraca do
  minimum dopiero po 60 s stabilnego połączenia
- watchdog ciszy > 90 s (przeglądarka nie wystawia ping/pong do JS — martwe
  półotwarte połączenie inaczej wisi w nieskończoność)
- dane maszyny starsze niż 5 min znikają z ekranu; przy rozłączeniu dane NIE
  są czyszczone od razu (alarm nie znika przy 3-sekundowym czknięciu sieci —
  o niepewności mówi pomarańczowy pasek, migawka po reconnexie przywraca
  prawdę)
- utrata łączności WIDOCZNA: pomarańczowy pasek po 10 s + przywrócony pasek
  statusu; element MUSI istnieć w DOM, refreshStatus() ma mimo to osłonę
- MACHINES stało się filtrem wyświetlania; ?ws= i ?token= nadpisują
  konfigurację z adresu strony
- karty przez textContent; celowo BRAK ws.onerror (po błędzie przeglądarka
  i tak wywołuje onclose)

TESTY (test/testy_ekranu.py): 10 deterministycznych scenariuszy bez sieci —
skrypt wycinany z żywego HTML, WebSocket podmieniony fałszywką, wirtualny
czas Chromium. Scenariusz 6 to bezpośredni test regresji błędu reconnectu;
mutacja (usunięcie planowania reconnectu) wywraca scenariusze 6, 8 i 9.

CRON RESTARTUJĄCY CO 2 H MOŻNA USUNĄĆ — README opisuje czemu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 08:04:30 +02:00

498 lines
18 KiB
HTML

<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ALS Alert Screen</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: #000;
color: #fff;
height: 100vh;
overflow: hidden;
position: relative;
}
/* ── NORMAL VIEW ──────────────────────────────── */
/* Iframe jest ZAWSZE zamontowany, a widok alarmowy to nakładka nad nim
(pomysł z wersji kioskowej, "iframe optimalization"). Przełączanie
przez display:none wymuszało przeładowanie treści iframe'a przy każdym
powrocie z alarmu. */
#normal-view {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
border: none;
z-index: 1;
}
/* ── ALARM VIEW ──────────────────────────────── */
#alarm-view {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
z-index: 2;
opacity: 0;
pointer-events: none;
display: flex;
flex-direction: column;
background: #0d0000;
transition: opacity 0.3s ease;
}
#alarm-view.active {
opacity: 1;
pointer-events: auto;
}
.alarm-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 18px 24px;
background: #1e0000;
border-bottom: 3px solid #ff0000;
animation: headerBlink 1s step-start infinite;
}
@keyframes headerBlink {
0%,
100% {
background: #1e0000;
}
50% {
background: #3a0000;
}
}
.alarm-icon {
font-size: clamp(28px, 4vw, 56px);
}
.alarm-title {
font-size: clamp(22px, 3.5vw, 52px);
font-weight: 900;
color: #ff2222;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.alarm-count {
font-size: clamp(18px, 2.5vw, 38px);
font-weight: 700;
color: #ff8888;
background: #3a0000;
border: 2px solid #ff3333;
border-radius: 8px;
padding: 4px 18px;
}
/* cards area */
#machines-grid {
flex: 1;
display: grid;
/* rows, columns & --cols/--rows set dynamically in JS */
gap: 3px;
background: #000;
overflow: hidden;
}
.machine-card {
background: #1a0000;
border: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5em;
overflow: hidden;
container-type: inline-size;
animation: cardGlow 2s ease-in-out infinite;
}
@keyframes cardGlow {
0%,
100% {
box-shadow: 0 0 12px rgba(255, 0, 0, 0.25);
}
50% {
box-shadow: 0 0 30px rgba(255, 0, 0, 0.7);
}
}
/* ── big machine number - skala przez --cols / --rows + cqw ── */
.card-machine-id {
font-size: min(
calc(55vw / var(--cols, 1)),
calc(55vh / var(--rows, 1)),
35cqw
);
font-weight: 900;
color: #ff2222;
line-height: 1;
letter-spacing: 0.04em;
text-align: center;
text-shadow: 0 0 24px rgba(255, 0, 0, 0.6);
}
/* ── OFFLINE BANNER ──────────────────────────── */
/* Widoczna z daleka informacja o utracie łączności z LuPort.
POMARAŃCZOWA, nie czerwona — czerwień jest zarezerwowana dla alarmów
maszyn; ten pasek mówi „ekran może pokazywać nieświeży obraz",
a nie „maszyna stoi". Poprzednia wersja tej strony traciła połączenia
PO CICHU (patrz README, sekcja Historia) — ten pasek jest wprost
wnioskiem z tamtej awarii: utrata łączności ma być widoczna z hali,
a nie odkrywana po fakcie. */
#offline-banner {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9998;
background: #7a4a00;
color: #ffd9a0;
border-bottom: 3px solid #ffaa33;
padding: 10px 24px;
text-align: center;
font-size: clamp(16px, 2vw, 28px);
font-weight: 700;
letter-spacing: 0.05em;
}
#offline-banner.active {
display: block;
animation: offlineBlink 2s step-start infinite;
}
@keyframes offlineBlink {
0%,
100% {
background: #7a4a00;
}
50% {
background: #9a5e00;
}
}
/* ── STATUS BAR ──────────────────────────────── */
#status-bar {
position: fixed;
bottom: 6px;
right: 10px;
font-size: 11px;
color: #444;
background: rgba(0, 0, 0, 0.6);
padding: 3px 10px;
border-radius: 4px;
pointer-events: none;
z-index: 9999;
}
</style>
</head>
<body>
<iframe
id="normal-view"
src="http://192.168.40.102/terminal/ift/5/PL/index"
allowfullscreen
></iframe>
<div id="alarm-view">
<div class="alarm-header">
<span class="alarm-icon"></span>
<span class="alarm-title">Alarm</span>
<span class="alarm-count" id="alarm-count">0</span>
<span class="alarm-icon"></span>
</div>
<div id="machines-grid"></div>
</div>
<div id="offline-banner">⚠ BRAK POŁĄCZENIA Z LUPORT — DANE MOGĄ BYĆ NIEAKTUALNE ⚠</div>
<!-- Ten element MUSI istnieć w DOM. Poprzednia wersja miała go
zakomentowanego, a refreshStatus() dalej po niego sięgał — TypeError
w ws.onclose przerywał handler PRZED zaplanowaniem reconnectu i każda
maszyna po pierwszym zerwaniu połączenia milkła na zawsze (naprawiane
restartem z crona co 2 h). Jeśli pasek ma być niewidoczny, ukryj go
przez CSS (display:none), nigdy przez usunięcie/komentarz. -->
<div id="status-bar">WS: łączenie…</div>
<script>
// ══ KONFIGURACJA ══════════════════════════════════════════════════════
// Kanał ZBIORCZY nowego systemu — jedno połączenie niesie aktualizacje
// wszystkich maszyn (stara wersja: osobne połączenie na każdą z 35
// maszyn). Po nawiązaniu połączenia serwer wysyła migawkę stanu
// wszystkich maszyn (ramki "machine_data"), więc po reconnexie trwający
// alarm wraca na ekran natychmiast, nie po cyklu scrapera.
const WS_URL = "ws://luport.local/ws/als-data/";
// Nowy system wymaga uwierzytelnienia WebSocket. Wstaw token konta
// ApiUser (Django Admin -> Users -> Api users -> "bot-als-alert-screen";
// konto NIE potrzebuje żadnych uprawnień — sam odbiór strumienia ich nie
// wymaga). Można też podać token w adresie strony: ?token=...
const WS_TOKEN = "WSTAW-TOKEN-APIUSER";
// Filtr maszyn pokazywanych na ekranie. Kanał zbiorczy niesie WSZYSTKIE
// maszyny znane LuPortowi — pusta lista ([]) oznacza „pokazuj wszystkie".
const MACHINES = [
"1.64", "1.76", "2.60", "2.63", "2.67", "2.72", "2.74",
"3.01", "3.04", "3.08", "3.22", "3.26", "3.51", "3.55",
"3.56", "3.59", "3.61", "3.66", "3.69", "3.70", "3.71",
"3.73", "3.75", "4.05", "4.09", "4.14", "4.52", "4.53",
"4.57", "4.58", "4.62", "4.65", "4.68", "5.29", "5.54",
];
// Reconnect: wykładniczy odstęp z losowym rozrzutem, żeby po awarii
// serwera ekran nie młócił połączeń co sekundę w nieskończoność.
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const RECONNECT_JITTER_MS = 2000;
// Watchdog martwego połączenia. Przeglądarka NIE wystawia ping/pong do
// JS — gdy trasa sieciowa umrze bez zamknięcia TCP, `onclose` może nie
// przyjść nigdy (dokładnie ten scenariusz wcześniej „leczył" restart
// z crona). Na kanale zbiorczym dane płyną praktycznie ciągle (35 maszyn
// x cykl scrapera 30 s), więc cisza dłuższa niż WATCHDOG_SILENCE_MS
// oznacza martwe połączenie: zamykamy je ręcznie, co uruchamia zwykłą
// ścieżkę reconnectu.
const WATCHDOG_SILENCE_MS = 90000;
const WATCHDOG_CHECK_MS = 15000;
// Dane maszyny starsze niż STALE_MS znikają z ekranu. Bez tego alarm
// maszyny usuniętej z widgetu ALS (albo trwale wyłączonej) wisiałby na
// ekranie w nieskończoność — klucz w Redisie wygasa po 2x cyklu
// scrapera, więc świeże dane po prostu przestają przychodzić.
const STALE_MS = 5 * 60 * 1000;
// Po jakim czasie bez połączenia pokazać pomarańczowy pasek. Krótkie
// przerwy (restart api przy aktualizacji) nie muszą straszyć hali.
const OFFLINE_BANNER_AFTER_MS = 10000;
// ══ Debug mode ════════════════════════════════════════════════════════
// Otwórz stronę z ?debug aby wymusić widok alarmowy z przykładowymi
// danymi (bez połączenia WS). W konsoli: DEBUG_ALARM = true; updateDisplay();
let DEBUG_ALARM = new URLSearchParams(location.search).has("debug");
const DEBUG_DATA = {
"2.60": { machine: "2.60", status: "Alarm - brak materiału" },
"3.22": { machine: "3.22", status: "Alarm - awaria" },
"4.09": { machine: "4.09", status: "Alarm - przekroczony cykl" },
};
// ══ Stan ══════════════════════════════════════════════════════════════
const machineData = {}; // id -> { data, receivedAt }
let ws = null;
let wsConnected = false;
let reconnectDelay = RECONNECT_MIN_MS;
let reconnectTimer = null;
let stableTimer = null;
let lastMessageAt = 0;
let disconnectedSince = Date.now();
const machineFilter = MACHINES.length ? new Set(MACHINES) : null;
function wsUrlWithToken() {
const params = new URLSearchParams(location.search);
// Oba parametry mają nadpisania w adresie strony (?ws=...&token=...) —
// kiosk można wycelować w inny serwer bez edycji pliku, a testy strony
// w serwer lokalny.
const base = params.get("ws") || WS_URL;
const token = params.get("token") || WS_TOKEN;
return base + "?token=" + encodeURIComponent(token);
}
// ══ Połączenie ════════════════════════════════════════════════════════
function connect() {
clearTimeout(reconnectTimer);
if (ws) {
try {
ws.onclose = null; // stare gniazdo nie może zaplanować DRUGIEGO reconnectu
ws.close();
} catch (_) {}
}
ws = new WebSocket(wsUrlWithToken());
ws.onopen = () => {
wsConnected = true;
lastMessageAt = Date.now();
// Odstęp reconnectu wraca do minimum dopiero po chwili STABILNEGO
// połączenia — reset w onopen pozwalałby serwerowi, który przyjmuje
// i zaraz zrywa (np. zły token -> close 4001), młócić co sekundę.
clearTimeout(stableTimer);
stableTimer = setTimeout(() => {
reconnectDelay = RECONNECT_MIN_MS;
}, 60000);
refreshStatus();
};
ws.onmessage = (ev) => {
lastMessageAt = Date.now();
try {
const msg = JSON.parse(ev.data);
// "machine_data" = migawka po połączeniu, "machine_update" = strumień
// na żywo. Ta sama obsługa: liczy się najnowszy znany stan maszyny.
if (
(msg.type === "machine_data" || msg.type === "machine_update") &&
msg.data &&
msg.machine
) {
if (machineFilter && !machineFilter.has(msg.machine)) return;
machineData[msg.machine] = {
data: { ...msg.data, machine: msg.machine },
receivedAt: Date.now(),
};
updateDisplay();
refreshStatus();
}
} catch (e) {
/* ignoruj uszkodzone ramki */
}
};
ws.onclose = () => {
wsConnected = false;
disconnectedSince = Date.now();
clearTimeout(stableTimer);
refreshStatus();
reconnectTimer = setTimeout(connect, nextReconnectDelay());
};
// Celowo brak ws.onerror: po błędzie przeglądarka i tak wywołuje
// onclose, a osobny handler był w starej wersji tylko drugim miejscem,
// z którego dało się rzucić wyjątkiem.
refreshStatus();
}
function nextReconnectDelay() {
const delay = reconnectDelay + Math.random() * RECONNECT_JITTER_MS;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
return delay;
}
// Watchdog + odświeżenie widoku (przeterminowane dane, pasek offline)
// w jednym cyklu.
setInterval(() => {
if (DEBUG_ALARM) return;
if (
wsConnected &&
ws &&
ws.readyState === WebSocket.OPEN &&
Date.now() - lastMessageAt > WATCHDOG_SILENCE_MS
) {
// close() uruchomi onclose -> zwykła ścieżka reconnectu.
try {
ws.close();
} catch (_) {}
}
updateDisplay();
refreshStatus();
}, WATCHDOG_CHECK_MS);
// ══ Widok ═════════════════════════════════════════════════════════════
function isAlarm(entry) {
return (
entry &&
entry.data &&
typeof entry.data.status === "string" &&
entry.data.status.toLowerCase().includes("alarm")
);
}
function isFresh(entry) {
return entry && Date.now() - entry.receivedAt < STALE_MS;
}
function currentAlarms() {
if (DEBUG_ALARM) {
return Object.entries(DEBUG_DATA).map(([id, d]) => [id, { data: d }]);
}
return Object.entries(machineData)
.filter(([, entry]) => isFresh(entry) && isAlarm(entry))
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
}
function updateDisplay() {
const alarms = currentAlarms();
const alarmView = document.getElementById("alarm-view");
const countEl = document.getElementById("alarm-count");
if (alarms.length > 0) {
alarmView.classList.add("active");
countEl.textContent =
alarms.length === 1 ? "1 maszyna" : alarms.length + " maszyny";
renderCards(alarms);
} else {
alarmView.classList.remove("active");
}
}
function renderCards(alarms) {
const grid = document.getElementById("machines-grid");
grid.innerHTML = "";
const n = alarms.length;
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
grid.style.setProperty("--cols", cols);
grid.style.setProperty("--rows", rows);
alarms.forEach(([id, entry]) => {
const card = document.createElement("div");
card.className = "machine-card";
const machineId = document.createElement("div");
machineId.className = "card-machine-id";
machineId.textContent = entry.data.machine ?? id;
card.appendChild(machineId);
grid.appendChild(card);
});
}
function refreshStatus() {
// Osłona zamiast założenia, że element istnieje — dokładnie ten brak
// osłony ubijał reconnect w poprzedniej wersji (patrz komentarz przy
// #status-bar w HTML).
const bar = document.getElementById("status-bar");
if (bar) {
const fresh = Object.values(machineData).filter(isFresh).length;
bar.textContent = wsConnected
? `WS: połączono · maszyny z danymi: ${fresh}`
: "WS: rozłączono — ponawiam…";
}
const banner = document.getElementById("offline-banner");
if (banner) {
const offlineLongEnough =
!wsConnected && Date.now() - disconnectedSince > OFFLINE_BANNER_AFTER_MS;
banner.classList.toggle("active", offlineLongEnough && !DEBUG_ALARM);
}
}
// ══ Start ═════════════════════════════════════════════════════════════
if (DEBUG_ALARM) {
const bar = document.getElementById("status-bar");
if (bar) bar.textContent = "[DEBUG] widok alarmowy";
} else {
connect();
}
updateDisplay();
</script>
</body>
</html>