Files
als-alert-screen/als_alert_screen.html
sases 5e694f6b74 Komunikat offline mówi, co faktycznie nie działa: powiadamianie o alarmach
"Dane mogą być nieaktualne" wprowadzało w błąd — iframe pokazuje infoterminal
ALS bezpośrednio, więc jego dane są aktualne niezależnie od LuPorta. Przy
zerwanym WebSockecie pada wyłącznie nasza warstwa wykrywania alarmów i to
komunikat ma mówić. (Niuans opisany w README: karty na ekranie alarmowym przy
przerwie w trakcie alarmu mogą być nieświeże do 5 min, potem znikają.)

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

529 lines
19 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 INDICATOR ───────────────────────── */
/* Widoczna z daleka informacja o utracie łączności z LuPort — CIENKA
pulsująca RAMKA wokół całego ekranu plus mała plakietka w rogu.
Wcześniejsza pełnoekranowa belka u góry zasłaniała dane ALS
w iframe (zgłoszone 2026-07-31) — ramka niczego nie zakrywa,
a z daleka widać ją równie dobrze. POMARAŃCZOWA, nie czerwona:
czerwień jest zarezerwowana dla alarmów maszyn. Ramka mówi
„powiadamianie o alarmach nie działa" — dane samego infoterminala
w iframe są aktualne niezależnie od LuPorta (to bezpośrednie
źródło), pada wyłącznie nasza warstwa wykrywania alarmów. Utrata
łączności MA być widoczna z hali — poprzednia wersja strony traciła
połączenia po cichu (README, Historia). */
#offline-indicator {
display: none;
}
#offline-indicator.active {
display: block;
}
#offline-frame {
position: fixed;
inset: 0;
border: 6px solid #ffaa33;
pointer-events: none;
z-index: 9998;
animation: offlinePulse 2s ease-in-out infinite;
}
@keyframes offlinePulse {
0%,
100% {
border-color: #ffaa33;
box-shadow: inset 0 0 20px rgba(255, 170, 51, 0.25);
}
50% {
border-color: #7a4a00;
box-shadow: inset 0 0 6px rgba(255, 170, 51, 0.1);
}
}
#offline-chip {
position: fixed;
top: 10px;
right: 12px;
background: #7a4a00;
color: #ffd9a0;
border: 1px solid #ffaa33;
border-radius: 6px;
padding: 4px 12px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.05em;
pointer-events: none;
z-index: 9999;
}
/* ── STATUS BAR ──────────────────────────────── */
#status-bar {
/* UKRYTY NA ŻYCZENIE — nakładał się na dane informacyjne ALS
w iframe. Ukrycie WYŁĄCZNIE przez CSS: usunięcie/zakomentowanie
elementu z HTML to dokładnie ten błąd, który ubijał reconnect
w starej wersji (TypeError w onclose — patrz README, Historia).
Do diagnostyki: usuń linię `display: none` albo w konsoli
document.getElementById("status-bar").style.display = "block". */
display: none;
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-indicator">
<div id="offline-frame"></div>
<div id="offline-chip">⚠ BRAK POŁĄCZENIA Z LUPORT — powiadamianie o alarmach nie działa</div>
</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 = () => {
// Znacznik początku przerwy ustawiany WYŁĄCZNIE przy przejściu
// połączone->rozłączone. Reset przy każdej NIEUDANEJ próbie
// reconnectu (jak wcześniej) zerował licznik ciszy co próbę i przy
// odstępie ponowień > 10 s wskaźnik offline cyklicznie znikał
// i wracał (zgłoszone 2026-07-31: „pasek pojawia się i znika") —
// zamiast świecić ciągle przez całą przerwę.
if (wsConnected) disconnectedSince = Date.now();
wsConnected = false;
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 indicator = document.getElementById("offline-indicator");
if (indicator) {
const offlineLongEnough =
!wsConnected && Date.now() - disconnectedSince > OFFLINE_BANNER_AFTER_MS;
indicator.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>