5e694f6b74
"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>
529 lines
19 KiB
HTML
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>
|