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>
This commit is contained in:
2026-07-31 08:04:30 +02:00
parent c893b5e975
commit 199e71327f
4 changed files with 542 additions and 284 deletions
+231 -190
View File
@@ -21,6 +21,10 @@
}
/* ── 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;
@@ -38,20 +42,17 @@
left: 0;
width: 100%;
height: 100vh;
z-index: 2; /* Warstwa na wierzchu */
/* Zamiast display: none, używamy przezroczystości i wyłączamy kliknięcia */
z-index: 2;
opacity: 0;
pointer-events: none;
display: flex;
flex-direction: column;
background: #0d0000;
transition: opacity 0.3s ease; /* Łagodne przejście */
transition: opacity 0.3s ease;
}
#alarm-view.active {
opacity: 1;
pointer-events: auto; /* Włączenie interakcji, jeśli potrzebne */
pointer-events: auto;
}
.alarm-header {
@@ -144,64 +145,42 @@
text-shadow: 0 0 24px rgba(255, 0, 0, 0.6);
}
.card-status {
font-size: min(
calc(7vw / var(--cols, 1)),
calc(7vh / var(--rows, 1)),
5cqw
);
font-weight: 700;
color: #ff9999;
text-transform: uppercase;
letter-spacing: 0.08em;
/* ── 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;
}
/* ── detail grid ── */
.card-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 14px;
border-top: 1px solid #3a0000;
padding-top: 8px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: clamp(9px, 0.8vw, 12px);
color: #664444;
text-transform: uppercase;
font-size: clamp(16px, 2vw, 28px);
font-weight: 700;
letter-spacing: 0.05em;
}
.info-value {
font-size: clamp(11px, 1.1vw, 16px);
color: #ddcccc;
font-weight: 700;
#offline-banner.active {
display: block;
animation: offlineBlink 2s step-start infinite;
}
.progress-row {
grid-column: 1 / -1;
margin-top: 2px;
}
.progress-bar {
height: 12px;
background: #3a0000;
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #990000, #ff4444);
border-radius: 6px;
transition: width 0.8s ease;
}
.progress-label {
text-align: right;
font-size: clamp(9px, 0.8vw, 11px);
color: #775555;
margin-top: 3px;
@keyframes offlineBlink {
0%,
100% {
background: #7a4a00;
}
50% {
background: #9a5e00;
}
}
/* ── STATUS BAR ──────────────────────────────── */
@@ -236,167 +215,217 @@
<div id="machines-grid"></div>
</div>
<!-- <div id="status-bar">WS: 0 / 0</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",
"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",
];
const WS_BASE = "ws://192.168.40.127/ws/als-data/";
const RECONNECT_MS = 5000;
// 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;
// ── Debug mode ────────────────────────────────────────────────────────────
// Otwórz stronę z ?debug aby wymusić widok alarmowy z przykładowymi danymi.
// Możesz też wpisać w konsoli: DEBUG_ALARM = true; updateDisplay();
// 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_MACHINES = ["2.60", "3.22", "4.09"];
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" },
"3.22": { machine: "3.22", status: "Alarm - awaria" },
"4.09": { machine: "4.09", status: "Alarm - przekroczony cykl" },
};
const machineData = {}; // id → last data payload
const wsStatus = {}; // id → 'connected' | 'error' | 'disconnected'
const wsMap = {}; // id → WebSocket instance
const dataTimers = {}; // id → stale-data timeout handle
// ══ Stan ══════════════════════════════════════════════════════════════
// Jeśli przez DATA_STALE_MS nie przyjdzie żadna wiadomość od maszyny
// (nawet przy technicznie otwartym WS), dane uznajemy za nieaktualne.
const DATA_STALE_MS = 2 * 60 * 1000; // 2 minuty
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 iframe = document.getElementById("normal-view");
const machineFilter = MACHINES.length ? new Set(MACHINES) : null;
iframe.onload = () => {
console.log("Iframe załadowany, startuję WebSockety...");
// Dajemy stronie w iframe jeszcze sekundę na przetworzenie własnego JS
setTimeout(() => {
MACHINES.forEach(connect);
}, 1000);
};
setTimeout(() => {
if (Object.keys(wsMap).length === 0) {
MACHINES.forEach(connect);
}
}, 10000);
function resetStaleTimer(id) {
clearTimeout(dataTimers[id]);
dataTimers[id] = setTimeout(() => {
delete machineData[id];
updateDisplay();
// NOWE: Jeśli dane są przestarzałe, zakładamy, że WebSocket to "zombie".
// Zabijamy go ręcznie, co wywoła onclose i sprowokuje ponowne połączenie.
if (wsMap[id] && wsStatus[id] === "connected") {
console.warn(`Brak danych dla ${id} - ubijam ciche połączenie`);
wsMap[id].close();
}
}, DATA_STALE_MS);
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);
}
// ── WebSocket management ──────────────────────────────────────────────────
// ══ Połączenie ════════════════════════════════════════════════════════
function connect(id) {
if (wsMap[id]) {
function connect() {
clearTimeout(reconnectTimer);
if (ws) {
try {
wsMap[id].close();
ws.onclose = null; // stare gniazdo nie może zaplanować DRUGIEGO reconnectu
ws.close();
} catch (_) {}
}
const ws = new WebSocket(WS_BASE + id + "/");
wsMap[id] = ws;
wsStatus[id] = "connecting";
ws = new WebSocket(wsUrlWithToken());
ws.onopen = () => {
wsStatus[id] = "connected";
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();
resetStaleTimer(id); // NOWE: Startujemy timer od razu po otwarciu
};
ws.onmessage = (ev) => {
lastMessageAt = Date.now();
try {
const msg = JSON.parse(ev.data);
if (msg.data) {
machineData[id] = { ...msg.data, _ts: Date.now() };
resetStaleTimer(id);
// "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) {
/* ignore malformed frames */
/* ignoruj uszkodzone ramki */
}
};
ws.onerror = () => {
wsStatus[id] = "error";
refreshStatus();
};
ws.onclose = () => {
wsStatus[id] = "disconnected";
clearTimeout(dataTimers[id]);
delete machineData[id];
updateDisplay();
wsConnected = false;
disconnectedSince = Date.now();
clearTimeout(stableTimer);
refreshStatus();
const reconnectDelay =
RECONNECT_MS + Math.floor(Math.random() * 3000);
setTimeout(() => connect(id), reconnectDelay);
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();
}
// ── Display logic ─────────────────────────────────────────────────────────
function nextReconnectDelay() {
const delay = reconnectDelay + Math.random() * RECONNECT_JITTER_MS;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
return delay;
}
function isAlarm(data) {
if (DEBUG_ALARM) return true; // w trybie debug wszystkie wstrzyknięte maszyny to alarmy
// 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 (
data &&
typeof data.status === "string" &&
data.status.toLowerCase().includes("alarm")
entry &&
entry.data &&
typeof entry.data.status === "string" &&
entry.data.status.toLowerCase().includes("alarm")
);
}
function updateDisplay() {
const alarms = Object.entries(machineData)
.filter(([, d]) => isAlarm(d))
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");
@@ -423,32 +452,44 @@
grid.style.setProperty("--cols", cols);
grid.style.setProperty("--rows", rows);
alarms.forEach(([id, d]) => {
alarms.forEach(([id, entry]) => {
const card = document.createElement("div");
card.className = "machine-card";
card.innerHTML = `
<div class="card-machine-id">${d.machine ?? id}</div>
<!-- <div class="card-status">${d.status ?? "ALARM"}</div> -->
`;
const machineId = document.createElement("div");
machineId.className = "card-machine-id";
machineId.textContent = entry.data.machine ?? id;
card.appendChild(machineId);
grid.appendChild(card);
});
}
function refreshStatus() {
const connected = Object.values(wsStatus).filter(
(s) => s === "connected",
).length;
document.getElementById("status-bar").textContent =
`WS: ${connected} / ${MACHINES.length}`;
// 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);
}
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
// ══ Start ═════════════════════════════════════════════════════════════
if (DEBUG_ALARM) {
DEBUG_MACHINES.forEach((id) => {
machineData[id] = DEBUG_DATA[id];
});
document.getElementById("status-bar").textContent =
"[DEBUG] widok alarmowy";
const bar = document.getElementById("status-bar");
if (bar) bar.textContent = "[DEBUG] widok alarmowy";
} else {
connect();
}
updateDisplay();
</script>