Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Reachy Phone Home Logs</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --video-max-width: 720px; | |
| --video-aspect: 4 / 3; | |
| } | |
| body { | |
| margin: 0; | |
| font-family: "IBM Plex Mono", "SF Mono", Consolas, monospace; | |
| background: #0f1115; | |
| color: #e9e6e0; | |
| } | |
| header { | |
| padding: 16px 20px; | |
| border-bottom: 1px solid #232836; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .container { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| header h1 { | |
| margin: 0; | |
| font-size: 18px; | |
| } | |
| .status { | |
| font-size: 12px; | |
| color: #9aa4b2; | |
| } | |
| #video { | |
| padding: 16px 20px 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .video-frame { | |
| width: 100%; | |
| max-width: var(--video-max-width); | |
| aspect-ratio: var(--video-aspect); | |
| border-radius: 12px; | |
| border: 1px solid #232836; | |
| overflow: hidden; | |
| position: relative; | |
| background: #0b0e13; | |
| } | |
| #video img { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| display: block; | |
| } | |
| .video-placeholder { | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 12px; | |
| background: #0b0e13; | |
| color: #7c8696; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 12px; | |
| letter-spacing: 0.3px; | |
| } | |
| .video-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .video-controls button { | |
| background: #1b2230; | |
| color: #e9e6e0; | |
| border: 1px solid #2b3447; | |
| padding: 8px 14px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| } | |
| .video-controls button:hover { | |
| background: #242d3f; | |
| } | |
| .warning { | |
| font-size: 12px; | |
| color: #d5a66b; | |
| } | |
| .instructions { | |
| margin: 16px 20px 0; | |
| max-width: 960px; | |
| padding: 14px 16px; | |
| border-radius: 12px; | |
| border: 1px solid #232836; | |
| background: #0b0e13; | |
| } | |
| .instructions h2 { | |
| margin: 0 0 8px 0; | |
| font-size: 14px; | |
| letter-spacing: 0.4px; | |
| text-transform: uppercase; | |
| color: #9aa4b2; | |
| } | |
| .instructions p { | |
| margin: 0 0 8px 0; | |
| color: #c9d1d9; | |
| } | |
| .instructions ol { | |
| margin: 0 0 8px 0; | |
| padding-left: 18px; | |
| color: #c9d1d9; | |
| } | |
| .instructions ul { | |
| margin: 0; | |
| padding-left: 18px; | |
| color: #c9d1d9; | |
| } | |
| .instructions li { | |
| margin-bottom: 6px; | |
| } | |
| .muted { | |
| color: #7c8696; | |
| } | |
| .cards { | |
| display: grid; | |
| gap: 12px; | |
| grid-template-columns: repeat(2, minmax(220px, 1fr)); | |
| margin: 16px 20px 40px; | |
| max-width: 960px; | |
| width: min(960px, 100%); | |
| } | |
| .tabs { | |
| display: flex; | |
| gap: 8px; | |
| margin: 16px 20px 0; | |
| } | |
| .tab-btn { | |
| background: #1b2230; | |
| color: #e9e6e0; | |
| border: 1px solid #2b3447; | |
| padding: 6px 12px; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| } | |
| .tab-btn.active { | |
| background: #2a3448; | |
| } | |
| .panel { | |
| width: 100%; | |
| max-width: 960px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .hero-img { | |
| width: min(720px, 100%); | |
| border-radius: 12px; | |
| border: 1px solid #232836; | |
| margin: 12px 20px 0; | |
| } | |
| .settings-card { | |
| border: 1px solid #232836; | |
| background: #0b0e13; | |
| border-radius: 12px; | |
| padding: 16px; | |
| flex: 0 0 420px; | |
| max-width: 420px; | |
| } | |
| .settings-row { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .settings-row label { | |
| font-size: 12px; | |
| color: #c9d1d9; | |
| flex: 1 1 140px; | |
| } | |
| .settings-row input, | |
| .settings-row select { | |
| background: #10141c; | |
| border: 1px solid #2b3447; | |
| color: #e9e6e0; | |
| padding: 6px 8px; | |
| border-radius: 8px; | |
| max-width: 100%; | |
| box-sizing: border-box; | |
| flex: 1 1 160px; | |
| } | |
| .settings-actions { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .settings-actions select { | |
| flex: 1 1 160px; | |
| min-width: 0; | |
| } | |
| .settings-grid { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| margin: 16px auto 40px; | |
| width: min(960px, 100%); | |
| box-sizing: border-box; | |
| justify-content: center; | |
| } | |
| .settings-card h4 { | |
| margin: 0 0 10px 0; | |
| font-size: 13px; | |
| color: #e9e6e0; | |
| } | |
| .card { | |
| border: 1px solid #232836; | |
| background: #0b0e13; | |
| border-radius: 12px; | |
| padding: 10px 12px; | |
| } | |
| .card h3 { | |
| margin: 0 0 8px 0; | |
| font-size: 13px; | |
| color: #e9e6e0; | |
| } | |
| .card .status-pill { | |
| display: inline-block; | |
| padding: 3px 8px; | |
| border-radius: 999px; | |
| font-size: 11px; | |
| background: #1b2230; | |
| color: #9aa4b2; | |
| border: 1px solid #2b3447; | |
| } | |
| .card.active .status-pill { | |
| background: #1a3b2c; | |
| color: #d6f8e4; | |
| border-color: #25533e; | |
| } | |
| .card.alert .status-pill { | |
| background: #4a1f1f; | |
| color: #ffd6d6; | |
| border-color: #6b2b2b; | |
| } | |
| .card .sub { | |
| margin-top: 6px; | |
| font-size: 11px; | |
| color: #7c8696; | |
| } | |
| .card-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| } | |
| .event-line { | |
| margin: 0 20px 20px; | |
| max-width: 960px; | |
| color: #9aa4b2; | |
| font-size: 12px; | |
| text-align: center; | |
| } | |
| .log-panel { | |
| width: 100%; | |
| max-width: 720px; | |
| border-radius: 12px; | |
| border: 1px solid #232836; | |
| background: #0b0e13; | |
| padding: 10px 12px; | |
| } | |
| .log-toolbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 8px; | |
| font-size: 12px; | |
| color: #9aa4b2; | |
| } | |
| .log-toolbar button { | |
| background: #1b2230; | |
| color: #e9e6e0; | |
| border: 1px solid #2b3447; | |
| padding: 6px 10px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| } | |
| .log-toolbar button:hover { | |
| background: #242d3f; | |
| } | |
| .log-body { | |
| height: 180px; | |
| overflow-y: auto; | |
| white-space: pre-wrap; | |
| line-height: 1.4; | |
| font-size: 12px; | |
| color: #c9d1d9; | |
| } | |
| .log-line { | |
| margin-bottom: 6px; | |
| } | |
| .status-actions { | |
| display: flex; | |
| justify-content: center; | |
| margin: 8px 0 16px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>Reachy Phone Home Logs</h1> | |
| <div class="status" id="status">Connecting...</div> | |
| </header> | |
| <div class="container"> | |
| <img class="hero-img" src="/static/reachy_phone_home.PNG" alt="Reachy Phone Home" /> | |
| <div class="tabs"> | |
| <button class="tab-btn active" id="tabStatus">Status</button> | |
| <button class="tab-btn" id="tabSettings">Settings</button> | |
| </div> | |
| <div class="panel" id="panelStatus"> | |
| <div class="status-actions"> | |
| <button id="quipsToggleBtn" class="tab-btn" type="button">Reachy Audio: On</button> | |
| </div> | |
| <div class="cards"> | |
| <div class="card" id="cardPhoneHome"> | |
| <div class="card-header"> | |
| <h3>Phone Home</h3> | |
| <span class="status-pill" id="statusPhoneHome">Waiting</span> | |
| </div> | |
| <div class="sub">Place phone in front of Reachy</div> | |
| </div> | |
| <div class="card" id="cardPhoneDetected"> | |
| <div class="card-header"> | |
| <h3>Phone Detected</h3> | |
| <span class="status-pill" id="statusPhoneDetected">Unknown</span> | |
| </div> | |
| <div class="sub">Phone seen by camera</div> | |
| </div> | |
| <div class="card" id="cardPhoneUse"> | |
| <div class="card-header"> | |
| <h3>Phone Use</h3> | |
| <span class="status-pill" id="statusPhoneUse">No</span> | |
| </div> | |
| <div class="sub">Phone overlaps person</div> | |
| </div> | |
| <div class="card" id="cardReachyStatus"> | |
| <div class="card-header"> | |
| <h3>Reachy’s Status</h3> | |
| <span class="status-pill" id="statusReachy">Idle</span> | |
| </div> | |
| <div class="sub" id="subReachy">Waiting for signals</div> | |
| </div> | |
| </div> | |
| <div id="video"> | |
| <div class="video-frame"> | |
| <div id="videoPlaceholder" class="video-placeholder">Debug video disabled</div> | |
| <img id="videoStream" data-src="/stream.mjpg" alt="Reachy camera stream" /> | |
| </div> | |
| <div class="video-controls"> | |
| <button id="debugBtn" type="button">Enable debug video</button> | |
| <span class="warning">Debug video may affect performance.</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel" id="panelSettings" style="display: none;"> | |
| <div class="settings-grid"> | |
| <div class="settings-card"> | |
| <h4>Weights</h4> | |
| <div class="settings-row"> | |
| <label for="weightsSelect">Download weights</label> | |
| </div> | |
| <div class="settings-actions"> | |
| <select id="weightsSelect"> | |
| <option value="yolo26l">yolo26l</option> | |
| <option value="yolo26m">yolo26m</option> | |
| <option value="yolo26s">yolo26s</option> | |
| <option value="yolo26n">yolo26n</option> | |
| </select> | |
| <button id="downloadWeightsBtn" class="tab-btn" type="button">Download</button> | |
| </div> | |
| </div> | |
| <div class="settings-card"> | |
| <h4>Detection</h4> | |
| <div class="settings-row"> | |
| <label for="confInput">Confidence</label> | |
| <input id="confInput" type="number" min="0.05" max="0.9" step="0.01" value="0.15" /> | |
| </div> | |
| <div class="settings-row"> | |
| <label for="goodJobSelect">Celebration delay</label> | |
| <select id="goodJobSelect"> | |
| <option value="10">10s</option> | |
| <option value="30" selected>30s</option> | |
| <option value="60">1 min</option> | |
| <option value="120">2 min</option> | |
| <option value="300">5 min</option> | |
| <option value="600">10 min</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="settings-card"> | |
| <h4>Reachy Audio</h4> | |
| <div class="settings-row"> | |
| <label for="ambientSelect">Ambient interval</label> | |
| <select id="ambientSelect"> | |
| <option value="10">10s</option> | |
| <option value="30" selected>30s</option> | |
| <option value="60">1 min</option> | |
| <option value="600">10 min</option> | |
| </select> | |
| </div> | |
| <div class="settings-row"> | |
| <label for="curiousInput">Curious interval (sec)</label> | |
| <input id="curiousInput" type="number" min="1" max="30" step="1" value="5" /> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="settings-actions"> | |
| <button id="saveSettingsBtn" class="tab-btn" type="button">Save</button> | |
| <span class="muted" id="settingsStatus"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let lastId = 0; | |
| const statusEl = document.getElementById("status"); | |
| const debugBtn = document.getElementById("debugBtn"); | |
| const videoStream = document.getElementById("videoStream"); | |
| const videoPlaceholder = document.getElementById("videoPlaceholder"); | |
| const tabStatus = document.getElementById("tabStatus"); | |
| const tabSettings = document.getElementById("tabSettings"); | |
| const panelStatus = document.getElementById("panelStatus"); | |
| const panelSettings = document.getElementById("panelSettings"); | |
| const weightsSelect = document.getElementById("weightsSelect"); | |
| const downloadWeightsBtn = document.getElementById("downloadWeightsBtn"); | |
| const confInput = document.getElementById("confInput"); | |
| const goodJobSelect = document.getElementById("goodJobSelect"); | |
| const ambientSelect = document.getElementById("ambientSelect"); | |
| const curiousInput = document.getElementById("curiousInput"); | |
| const saveSettingsBtn = document.getElementById("saveSettingsBtn"); | |
| const settingsStatus = document.getElementById("settingsStatus"); | |
| let videoEnabled = false; | |
| let currentState = null; | |
| let phoneDetected = null; | |
| let phoneUse = false; | |
| let phoneHome = false; | |
| const logsEnabled = true; | |
| const cardPhoneDetected = document.getElementById("cardPhoneDetected"); | |
| const cardPhoneUse = document.getElementById("cardPhoneUse"); | |
| const cardReachyStatus = document.getElementById("cardReachyStatus"); | |
| const cardPhoneHome = document.getElementById("cardPhoneHome"); | |
| const statusPhoneDetected = document.getElementById("statusPhoneDetected"); | |
| const statusPhoneUse = document.getElementById("statusPhoneUse"); | |
| const statusReachy = document.getElementById("statusReachy"); | |
| const subReachy = document.getElementById("subReachy"); | |
| const statusPhoneHome = document.getElementById("statusPhoneHome"); | |
| const quipsToggleBtn = document.getElementById("quipsToggleBtn"); | |
| let quipsEnabled = true; | |
| let aspectTimer = null; | |
| let snapshotTimer = null; | |
| function updateAspectFromStream() { | |
| if (videoStream.naturalWidth && videoStream.naturalHeight) { | |
| document.documentElement.style.setProperty( | |
| "--video-aspect", | |
| `${videoStream.naturalWidth} / ${videoStream.naturalHeight}` | |
| ); | |
| } | |
| } | |
| function updateAspectFromSnapshot() { | |
| const img = new Image(); | |
| img.onload = () => { | |
| if (img.naturalWidth && img.naturalHeight) { | |
| document.documentElement.style.setProperty( | |
| "--video-aspect", | |
| `${img.naturalWidth} / ${img.naturalHeight}` | |
| ); | |
| } | |
| }; | |
| img.src = `/snapshot.jpg?t=${Date.now()}`; | |
| } | |
| function setVideoEnabled(enabled) { | |
| videoEnabled = enabled; | |
| if (videoEnabled) { | |
| debugBtn.textContent = "Disable debug video"; | |
| fetch("/debug?enabled=1") | |
| .then(() => { | |
| const base = videoStream.dataset.src; | |
| videoStream.src = `${base}?t=${Date.now()}`; | |
| videoStream.style.display = "block"; | |
| videoPlaceholder.style.display = "none"; | |
| updateAspectFromStream(); | |
| if (aspectTimer) { | |
| clearInterval(aspectTimer); | |
| } | |
| aspectTimer = setInterval(updateAspectFromStream, 500); | |
| updateAspectFromSnapshot(); | |
| if (snapshotTimer) { | |
| clearInterval(snapshotTimer); | |
| } | |
| snapshotTimer = setInterval(updateAspectFromSnapshot, 2000); | |
| }) | |
| .catch(() => {}); | |
| } else { | |
| videoStream.removeAttribute("src"); | |
| videoStream.style.display = "none"; | |
| videoPlaceholder.style.display = "flex"; | |
| debugBtn.textContent = "Enable debug video"; | |
| fetch("/debug?enabled=0").catch(() => {}); | |
| if (aspectTimer) { | |
| clearInterval(aspectTimer); | |
| aspectTimer = null; | |
| } | |
| if (snapshotTimer) { | |
| clearInterval(snapshotTimer); | |
| snapshotTimer = null; | |
| } | |
| } | |
| } | |
| debugBtn.addEventListener("click", () => { | |
| setVideoEnabled(!videoEnabled); | |
| }); | |
| setVideoEnabled(false); | |
| videoStream.addEventListener("load", updateAspectFromStream); | |
| function setTab(name) { | |
| if (name === "settings") { | |
| panelStatus.style.display = "none"; | |
| panelSettings.style.display = "flex"; | |
| tabStatus.classList.remove("active"); | |
| tabSettings.classList.add("active"); | |
| } else { | |
| panelStatus.style.display = "block"; | |
| panelSettings.style.display = "none"; | |
| tabStatus.classList.add("active"); | |
| tabSettings.classList.remove("active"); | |
| } | |
| } | |
| tabStatus.addEventListener("click", () => setTab("status")); | |
| tabSettings.addEventListener("click", () => setTab("settings")); | |
| async function loadSettings() { | |
| try { | |
| const res = await fetch("/settings"); | |
| const data = await res.json(); | |
| if (data.conf !== null && data.conf !== undefined) { | |
| confInput.value = data.conf; | |
| } | |
| if (data.good_job_heartbeats !== null && data.good_job_heartbeats !== undefined) { | |
| const seconds = data.good_job_heartbeats * 10; | |
| goodJobSelect.value = String(seconds); | |
| } | |
| if (data.quips_enabled !== null && data.quips_enabled !== undefined) { | |
| quipsEnabled = Boolean(data.quips_enabled); | |
| quipsToggleBtn.textContent = quipsEnabled ? "Reachy Audio: On" : "Reachy Audio: Off"; | |
| } | |
| if (data.ambient_interval_sec !== null && data.ambient_interval_sec !== undefined) { | |
| ambientSelect.value = String(data.ambient_interval_sec); | |
| } | |
| if (data.curious_interval_sec !== null && data.curious_interval_sec !== undefined) { | |
| curiousInput.value = String(data.curious_interval_sec); | |
| } | |
| } catch (err) { | |
| settingsStatus.textContent = "Failed to load settings"; | |
| } | |
| } | |
| saveSettingsBtn.addEventListener("click", async () => { | |
| settingsStatus.textContent = "Saving..."; | |
| const confVal = parseFloat(confInput.value); | |
| const seconds = parseInt(goodJobSelect.value, 10); | |
| const beats = Math.max(1, Math.round(seconds / 10)); | |
| try { | |
| await fetch("/settings", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| conf: confVal, | |
| good_job_heartbeats: beats, | |
| ambient_interval_sec: parseFloat(ambientSelect.value), | |
| curious_interval_sec: parseFloat(curiousInput.value), | |
| }), | |
| }); | |
| settingsStatus.textContent = "Saved"; | |
| } catch (err) { | |
| settingsStatus.textContent = "Save failed"; | |
| } | |
| }); | |
| quipsToggleBtn.addEventListener("click", async () => { | |
| quipsEnabled = !quipsEnabled; | |
| quipsToggleBtn.textContent = quipsEnabled ? "Reachy Audio: On" : "Reachy Audio: Off"; | |
| settingsStatus.textContent = "Saving..."; | |
| try { | |
| await fetch("/settings", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ quips_enabled: quipsEnabled }), | |
| }); | |
| settingsStatus.textContent = "Saved"; | |
| } catch (err) { | |
| settingsStatus.textContent = "Save failed"; | |
| } | |
| }); | |
| downloadWeightsBtn.addEventListener("click", async () => { | |
| settingsStatus.textContent = "Downloading..."; | |
| try { | |
| await fetch("/download_weights", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ weights: weightsSelect.value }), | |
| }); | |
| settingsStatus.textContent = "Download queued"; | |
| } catch (err) { | |
| settingsStatus.textContent = "Download failed"; | |
| } | |
| }); | |
| function setCard(card, statusEl, active, text, alert = false) { | |
| card.classList.toggle("active", active); | |
| card.classList.toggle("alert", alert); | |
| statusEl.textContent = text; | |
| } | |
| function renderCards() { | |
| setCard( | |
| cardPhoneDetected, | |
| statusPhoneDetected, | |
| phoneDetected === true, | |
| phoneDetected === null ? "Unknown" : phoneDetected ? "Yes" : "No", | |
| phoneDetected === false | |
| ); | |
| setCard( | |
| cardPhoneUse, | |
| statusPhoneUse, | |
| phoneUse, | |
| phoneUse ? "Detected" : "No", | |
| phoneUse | |
| ); | |
| let reachyLabel = "Idle"; | |
| let reachySub = "Waiting for signals"; | |
| let reachyActive = false; | |
| if (currentState === "tracking_phone") { | |
| reachyLabel = "Tracking Phone"; | |
| reachySub = "Following the phone"; | |
| reachyActive = true; | |
| } else if (currentState === "tracking_person") { | |
| if (phoneDetected === false) { | |
| reachyLabel = "Tracking Person & Searching Phone"; | |
| reachySub = "Scanning for you and the phone"; | |
| } else { | |
| reachyLabel = "Tracking Person"; | |
| reachySub = "Looking for you"; | |
| } | |
| reachyActive = true; | |
| } else if (currentState === "searching_phone" || currentState === "looking_down") { | |
| reachyLabel = "Searching Phone"; | |
| reachySub = "Scanning for the phone"; | |
| reachyActive = true; | |
| } else if (phoneDetected === false) { | |
| reachyLabel = "Searching Phone"; | |
| reachySub = "Scanning for the phone"; | |
| reachyActive = true; | |
| } | |
| setCard(cardReachyStatus, statusReachy, reachyActive, reachyLabel); | |
| subReachy.textContent = reachySub; | |
| const phoneHomeActive = phoneHome; | |
| setCard( | |
| cardPhoneHome, | |
| statusPhoneHome, | |
| phoneHomeActive, | |
| phoneHomeActive ? "Home" : "Waiting" | |
| ); | |
| } | |
| function handleLine(text) { | |
| if (text.includes("[state]")) { | |
| const state = text.split("[state]")[1].trim(); | |
| if (state === "phone use detected") { | |
| phoneUse = true; | |
| } else if (state === "phone use stopped") { | |
| phoneUse = false; | |
| } else if (state === "phone home") { | |
| phoneHome = true; | |
| } else if (state === "phone home cleared") { | |
| phoneHome = false; | |
| } else { | |
| currentState = state; | |
| if (state === "tracking_phone") { | |
| phoneDetected = true; | |
| } else if ( | |
| state === "searching_phone" || | |
| state === "tracking_person" || | |
| state === "looking_down" | |
| ) { | |
| phoneDetected = false; | |
| } | |
| } | |
| } | |
| if (text.includes("[heartbeat] phone_detected=")) { | |
| phoneDetected = text.includes("phone_detected=yes"); | |
| } | |
| renderCards(); | |
| } | |
| async function poll() { | |
| if (!logsEnabled) { | |
| statusEl.textContent = "Logs disabled"; | |
| } | |
| try { | |
| const res = await fetch(`/logs?since=${lastId}`); | |
| const data = await res.json(); | |
| data.lines.forEach((line) => { | |
| lastId = Math.max(lastId, line.id); | |
| handleLine(line.text); | |
| }); | |
| statusEl.textContent = "Live"; | |
| } catch (err) { | |
| statusEl.textContent = "Disconnected"; | |
| handleLine("[warn] log stream disconnected"); | |
| } | |
| setTimeout(poll, 1000); | |
| } | |
| poll(); | |
| loadSettings(); | |
| </script> | |
| </body> | |
| </html> | |