feat(Q4): kiosk ambient mode with camera rotation, alert takeover, dimming
Add GET /kiosk/ambient route and standalone fullscreen template with rotating camera snapshots (crossfade), top bar clock/date, pet status bottom bar, SSE-driven alert takeover with HLS playback, configurable screen dimming, and 6-hour auto-refresh. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d69bf6d6af
commit
b4dbb41624
29
tests/unit/test_kiosk_ambient.py
Normal file
29
tests/unit/test_kiosk_ambient.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import pytest
|
||||||
|
from vigilar.config import VigilarConfig
|
||||||
|
from vigilar.web.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kiosk_app():
|
||||||
|
cfg = VigilarConfig()
|
||||||
|
app = create_app(cfg)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambient_route_exists(kiosk_app):
|
||||||
|
with kiosk_app.test_client() as c:
|
||||||
|
rv = c.get("/kiosk/ambient")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambient_contains_clock(kiosk_app):
|
||||||
|
with kiosk_app.test_client() as c:
|
||||||
|
rv = c.get("/kiosk/ambient")
|
||||||
|
assert b"clock" in rv.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambient_contains_hls(kiosk_app):
|
||||||
|
with kiosk_app.test_client() as c:
|
||||||
|
rv = c.get("/kiosk/ambient")
|
||||||
|
assert b"hls.min.js" in rv.data
|
||||||
@ -1,4 +1,4 @@
|
|||||||
"""Kiosk blueprint — fullscreen 2x2 grid for TV display."""
|
"""Kiosk blueprint — fullscreen 2x2 grid and ambient mode."""
|
||||||
|
|
||||||
from flask import Blueprint, current_app, render_template
|
from flask import Blueprint, current_app, render_template
|
||||||
|
|
||||||
@ -10,3 +10,12 @@ def kiosk_view():
|
|||||||
cfg = current_app.config.get("VIGILAR_CONFIG")
|
cfg = current_app.config.get("VIGILAR_CONFIG")
|
||||||
cameras = cfg.cameras if cfg else []
|
cameras = cfg.cameras if cfg else []
|
||||||
return render_template("kiosk.html", cameras=cameras)
|
return render_template("kiosk.html", cameras=cameras)
|
||||||
|
|
||||||
|
|
||||||
|
@kiosk_bp.route("/ambient")
|
||||||
|
def ambient_view():
|
||||||
|
cfg = current_app.config.get("VIGILAR_CONFIG")
|
||||||
|
cameras = cfg.cameras if cfg else []
|
||||||
|
raw_kiosk = cfg.kiosk if cfg and hasattr(cfg, "kiosk") else None
|
||||||
|
kiosk_cfg = raw_kiosk.model_dump() if raw_kiosk is not None and hasattr(raw_kiosk, "model_dump") else raw_kiosk
|
||||||
|
return render_template("kiosk/ambient.html", cameras=cameras, kiosk_config=kiosk_cfg)
|
||||||
|
|||||||
465
vigilar/web/templates/kiosk/ambient.html
Normal file
465
vigilar/web/templates/kiosk/ambient.html
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="refresh" content="21600">
|
||||||
|
<title>Vigilar — Ambient</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
landscape: orientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
#ambient-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar (10%) ── */
|
||||||
|
#top-bar {
|
||||||
|
flex: 0 0 10%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
#clock {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 200;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
#date-label {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#weather-label {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #aaa;
|
||||||
|
min-width: 6rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Center camera (70%) ── */
|
||||||
|
#camera-area {
|
||||||
|
flex: 0 0 70%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.cam-slide {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1s ease-in-out;
|
||||||
|
}
|
||||||
|
.cam-slide.visible { opacity: 1; }
|
||||||
|
.cam-slide img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.cam-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: live-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
#no-camera {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #555;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bottom bar (20%) ── */
|
||||||
|
#bottom-bar {
|
||||||
|
flex: 0 0 20%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0 2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
#bottom-bar::-webkit-scrollbar { display: none; }
|
||||||
|
.pet-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 7rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.pet-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.pet-location {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pet-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
#bottom-placeholder {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Alert takeover ── */
|
||||||
|
#alert-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: #000;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
#alert-overlay.active { display: flex; }
|
||||||
|
#alert-video-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#alert-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
#alert-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.4rem 1.25rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: rgba(220, 53, 69, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
#alert-countdown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dim overlay ── */
|
||||||
|
#dim-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#dim-overlay.active { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="ambient-root">
|
||||||
|
<div id="top-bar">
|
||||||
|
<div id="clock">--:--</div>
|
||||||
|
<div id="date-label">Loading…</div>
|
||||||
|
<div id="weather-label">—</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="camera-area">
|
||||||
|
<div id="cam-slide-a" class="cam-slide visible">
|
||||||
|
<img id="cam-img-a" src="" alt="">
|
||||||
|
<div class="cam-label"><span class="live-dot"></span><span id="cam-name-a"></span></div>
|
||||||
|
</div>
|
||||||
|
<div id="cam-slide-b" class="cam-slide">
|
||||||
|
<img id="cam-img-b" src="" alt="">
|
||||||
|
<div class="cam-label"><span class="live-dot"></span><span id="cam-name-b"></span></div>
|
||||||
|
</div>
|
||||||
|
<div id="no-camera" style="display:none;">No cameras configured</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bottom-bar">
|
||||||
|
<span id="bottom-placeholder">Loading pet status…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert takeover -->
|
||||||
|
<div id="alert-overlay">
|
||||||
|
<div id="alert-video-wrap">
|
||||||
|
<video id="alert-video" autoplay muted playsinline></video>
|
||||||
|
<div id="alert-badge">Alert</div>
|
||||||
|
<div id="alert-countdown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen dim overlay -->
|
||||||
|
<div id="dim-overlay"></div>
|
||||||
|
|
||||||
|
<script src="/static/js/hls.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Config (from server, with JS fallbacks) ──────────────────────────
|
||||||
|
const serverCfg = {{ kiosk_config | tojson }};
|
||||||
|
const cfg = {
|
||||||
|
rotation_interval_s: (serverCfg && serverCfg.rotation_interval_s) || 10,
|
||||||
|
dim_start: (serverCfg && serverCfg.dim_start) || "23:00",
|
||||||
|
dim_end: (serverCfg && serverCfg.dim_end) || "06:00",
|
||||||
|
alert_timeout_s: (serverCfg && serverCfg.alert_timeout_s) || 30,
|
||||||
|
predator_alert_timeout_s: (serverCfg && serverCfg.predator_alert_timeout_s) || 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameras = {{ cameras | tojson }};
|
||||||
|
|
||||||
|
// ── Clock & Date ─────────────────────────────────────────────────────
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
document.getElementById('clock').textContent = hh + ':' + mm;
|
||||||
|
|
||||||
|
const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
||||||
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
document.getElementById('date-label').textContent =
|
||||||
|
days[now.getDay()] + ', ' + months[now.getMonth()] + ' ' + now.getDate();
|
||||||
|
}
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 60000);
|
||||||
|
|
||||||
|
// ── Screen dimming ───────────────────────────────────────────────────
|
||||||
|
function parseTOD(str) {
|
||||||
|
const [h, m] = str.split(':').map(Number);
|
||||||
|
return h * 60 + (m || 0);
|
||||||
|
}
|
||||||
|
function checkDim() {
|
||||||
|
const now = new Date();
|
||||||
|
const cur = now.getHours() * 60 + now.getMinutes();
|
||||||
|
const start = parseTOD(cfg.dim_start);
|
||||||
|
const end = parseTOD(cfg.dim_end);
|
||||||
|
let dimmed;
|
||||||
|
if (start > end) {
|
||||||
|
// spans midnight
|
||||||
|
dimmed = cur >= start || cur < end;
|
||||||
|
} else {
|
||||||
|
dimmed = cur >= start && cur < end;
|
||||||
|
}
|
||||||
|
document.getElementById('dim-overlay').classList.toggle('active', dimmed);
|
||||||
|
}
|
||||||
|
checkDim();
|
||||||
|
setInterval(checkDim, 60000);
|
||||||
|
|
||||||
|
// ── Camera rotation ──────────────────────────────────────────────────
|
||||||
|
let camIndex = 0;
|
||||||
|
let activeSlot = 'a'; // 'a' or 'b'
|
||||||
|
|
||||||
|
function snapshotUrl(cam) {
|
||||||
|
return '/cameras/' + cam.id + '/snapshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCamera(slot, cam) {
|
||||||
|
const img = document.getElementById('cam-img-' + slot);
|
||||||
|
const name = document.getElementById('cam-name-' + slot);
|
||||||
|
img.src = snapshotUrl(cam) + '?t=' + Date.now();
|
||||||
|
name.textContent = cam.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateCameras() {
|
||||||
|
if (!cameras || cameras.length === 0) {
|
||||||
|
document.getElementById('no-camera').style.display = 'flex';
|
||||||
|
document.getElementById('cam-slide-a').style.display = 'none';
|
||||||
|
document.getElementById('cam-slide-b').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
|
||||||
|
camIndex = (camIndex + 1) % cameras.length;
|
||||||
|
|
||||||
|
loadCamera(nextSlot, cameras[camIndex]);
|
||||||
|
|
||||||
|
// Crossfade
|
||||||
|
const showing = document.getElementById('cam-slide-' + activeSlot);
|
||||||
|
const incoming = document.getElementById('cam-slide-' + nextSlot);
|
||||||
|
incoming.classList.add('visible');
|
||||||
|
setTimeout(() => {
|
||||||
|
showing.classList.remove('visible');
|
||||||
|
activeSlot = nextSlot;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
if (cameras && cameras.length > 0) {
|
||||||
|
loadCamera('a', cameras[0]);
|
||||||
|
if (cameras.length > 1) {
|
||||||
|
loadCamera('b', cameras[1]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('no-camera').style.display = 'flex';
|
||||||
|
document.getElementById('cam-slide-a').style.display = 'none';
|
||||||
|
document.getElementById('cam-slide-b').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(rotateCameras, cfg.rotation_interval_s * 1000);
|
||||||
|
|
||||||
|
// ── Pet status ───────────────────────────────────────────────────────
|
||||||
|
function timeAgo(isoStr) {
|
||||||
|
if (!isoStr) return '';
|
||||||
|
const diff = Math.floor((Date.now() - new Date(isoStr).getTime()) / 1000);
|
||||||
|
if (diff < 60) return diff + 's ago';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
return Math.floor(diff / 3600) + 'h ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPetStatus() {
|
||||||
|
fetch('/pets/api/status')
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
const bar = document.getElementById('bottom-bar');
|
||||||
|
const placeholder = document.getElementById('bottom-placeholder');
|
||||||
|
if (!data || !data.pets || data.pets.length === 0) {
|
||||||
|
if (placeholder) placeholder.textContent = 'No pets tracked';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
|
// Rebuild pet cards (preserve existing to avoid flicker on re-render)
|
||||||
|
bar.innerHTML = '';
|
||||||
|
data.pets.forEach(pet => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'pet-card';
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="pet-name">' + (pet.name || 'Unknown') + '</div>' +
|
||||||
|
'<div class="pet-location">' + (pet.last_camera || '—') + '</div>' +
|
||||||
|
'<div class="pet-time">' + timeAgo(pet.last_seen) + '</div>';
|
||||||
|
bar.appendChild(card);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPetStatus();
|
||||||
|
setInterval(fetchPetStatus, 30000);
|
||||||
|
|
||||||
|
// ── Alert takeover (SSE) ─────────────────────────────────────────────
|
||||||
|
let alertTimer = null;
|
||||||
|
let alertHls = null;
|
||||||
|
|
||||||
|
function dismissAlert() {
|
||||||
|
if (alertTimer) { clearTimeout(alertTimer); alertTimer = null; }
|
||||||
|
if (alertHls) { alertHls.destroy(); alertHls = null; }
|
||||||
|
const vid = document.getElementById('alert-video');
|
||||||
|
vid.src = '';
|
||||||
|
document.getElementById('alert-overlay').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(event) {
|
||||||
|
const overlay = document.getElementById('alert-overlay');
|
||||||
|
const badge = document.getElementById('alert-badge');
|
||||||
|
const countdown = document.getElementById('alert-countdown');
|
||||||
|
const vid = document.getElementById('alert-video');
|
||||||
|
|
||||||
|
// Dismiss any previous alert
|
||||||
|
dismissAlert();
|
||||||
|
|
||||||
|
// Set badge text
|
||||||
|
const label = event.type || 'Alert';
|
||||||
|
badge.textContent = label;
|
||||||
|
|
||||||
|
// Choose timeout
|
||||||
|
const isPredator = (event.type || '').toLowerCase().includes('predator');
|
||||||
|
const timeout = (isPredator ? cfg.predator_alert_timeout_s : cfg.alert_timeout_s) * 1000;
|
||||||
|
|
||||||
|
// Load HLS feed if camera provided
|
||||||
|
if (event.camera_id) {
|
||||||
|
const hlsUrl = '/cameras/' + event.camera_id + '/stream.m3u8';
|
||||||
|
if (Hls && Hls.isSupported()) {
|
||||||
|
alertHls = new Hls();
|
||||||
|
alertHls.loadSource(hlsUrl);
|
||||||
|
alertHls.attachMedia(vid);
|
||||||
|
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
vid.src = hlsUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.classList.add('active');
|
||||||
|
|
||||||
|
// Countdown display
|
||||||
|
let remaining = Math.ceil(timeout / 1000);
|
||||||
|
countdown.textContent = remaining + 's';
|
||||||
|
const countdownInterval = setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
countdown.textContent = remaining + 's';
|
||||||
|
if (remaining <= 0) clearInterval(countdownInterval);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
alertTimer = setTimeout(() => {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
dismissAlert();
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE listener
|
||||||
|
function connectSSE() {
|
||||||
|
const es = new EventSource('/events/stream');
|
||||||
|
es.onmessage = function (e) {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(e.data);
|
||||||
|
if (event && event.type) {
|
||||||
|
showAlert(event);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
es.onerror = function () {
|
||||||
|
es.close();
|
||||||
|
// Reconnect after 5s
|
||||||
|
setTimeout(connectSSE, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connectSSE();
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user