199e71327f
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>
498 lines
18 KiB
HTML
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>
|