Sessions are in-memory, so a furtka-api restart logs the user out and /api/gateway/status returns 401. The settings page ignored that and kept its default "not connected" panel visible. Both panels now start hidden and a 401 redirects to /login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
606 lines
27 KiB
HTML
606 lines
27 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Settings · Furtka</title>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<link rel="stylesheet" href="/style.css">
|
||
</head>
|
||
<body>
|
||
<main class="wrap">
|
||
<nav class="nav">
|
||
<a class="brand" href="/">Furtka</a>
|
||
<div class="nav-links">
|
||
<a href="/">Home</a>
|
||
<a href="/apps">Apps</a>
|
||
<a href="/settings/" aria-current="page">Settings</a>
|
||
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
||
</div>
|
||
</nav>
|
||
|
||
<h1>Settings</h1>
|
||
<p class="lede">What this box knows about itself.</p>
|
||
|
||
<section>
|
||
<h2>About this box</h2>
|
||
<div class="card">
|
||
<dl class="kv">
|
||
<dt>Hostname</dt><dd id="set-hostname">—</dd>
|
||
<dt>IP address</dt><dd id="set-ip">—</dd>
|
||
<dt>Furtka version</dt><dd id="set-version">—</dd>
|
||
<dt>Kernel</dt><dd id="set-kernel">—</dd>
|
||
<dt>RAM</dt><dd id="set-ram">—</dd>
|
||
<dt>Docker</dt><dd id="set-docker">—</dd>
|
||
<dt>Uptime</dt><dd id="set-uptime">—</dd>
|
||
</dl>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Furtka updates</h2>
|
||
<div class="card">
|
||
<dl class="kv">
|
||
<dt>Installed</dt><dd id="upd-current">—</dd>
|
||
<dt>Latest available</dt><dd id="upd-latest">—</dd>
|
||
</dl>
|
||
<div class="update-actions">
|
||
<button id="check-updates-btn" class="secondary">Check for updates</button>
|
||
<button id="apply-update-btn" hidden>Update now</button>
|
||
</div>
|
||
<p id="update-status" class="hint"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Local HTTPS</h2>
|
||
<div class="card">
|
||
<p class="lede">
|
||
Serve this box over <code>https://<span id="https-host">—</span>/</code>
|
||
with a green padlock. Install the Furtka root CA once per device, then
|
||
optionally force every HTTP request to redirect.
|
||
</p>
|
||
<dl class="kv">
|
||
<dt>CA fingerprint (SHA-256)</dt><dd id="https-fingerprint">—</dd>
|
||
<dt>Reachable from this browser</dt><dd id="https-reachable">checking…</dd>
|
||
</dl>
|
||
<div class="update-actions">
|
||
<button id="https-download-btn" class="secondary">Download CA (.crt)</button>
|
||
<a href="/https-install/" class="inline-link">Per-OS install guide</a>
|
||
</div>
|
||
<label class="https-toggle" hidden id="https-force-wrap">
|
||
<input type="checkbox" id="https-force">
|
||
<span>Force HTTPS (redirect plain HTTP to HTTPS)</span>
|
||
</label>
|
||
<p class="hint" id="https-force-hint" hidden>
|
||
Enable this only after you've installed the CA and confirmed
|
||
<code>https://</code> works in this browser — otherwise the redirect
|
||
will leave you with a scary certificate warning.
|
||
</p>
|
||
<p id="https-status" class="hint"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Furtka Gateway</h2>
|
||
<div class="card">
|
||
<p class="lede">
|
||
Connect this box to a furtka-gateway to expose individual apps to the
|
||
internet over a WireGuard tunnel. Apps stay private until you explicitly
|
||
publish one from the <a href="/apps">Apps</a> page — connecting here only
|
||
opens the tunnel, nothing is exposed automatically.
|
||
</p>
|
||
<div id="gateway-disconnected" hidden>
|
||
<div class="field">
|
||
<label for="gw-url">Gateway URL</label>
|
||
<input type="text" id="gw-url" placeholder="https://gw.example.com">
|
||
</div>
|
||
<div class="field">
|
||
<label for="gw-token">Registration token</label>
|
||
<input type="password" id="gw-token" placeholder="from the gateway operator">
|
||
</div>
|
||
<div class="field">
|
||
<label for="gw-name">Box name</label>
|
||
<input type="text" id="gw-name" placeholder="my-box">
|
||
</div>
|
||
<div class="update-actions">
|
||
<button id="gateway-connect-btn">Connect</button>
|
||
</div>
|
||
</div>
|
||
<div id="gateway-connected" hidden>
|
||
<dl class="kv">
|
||
<dt>Gateway</dt><dd id="gw-connected-url">—</dd>
|
||
<dt>Tunnel</dt><dd id="gw-tunnel-status">—</dd>
|
||
</dl>
|
||
<div class="update-actions">
|
||
<button class="danger" id="gateway-disconnect-btn">Disconnect</button>
|
||
</div>
|
||
</div>
|
||
<p id="gateway-status" class="hint"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Appearance</h2>
|
||
<div class="card">
|
||
<dl class="kv">
|
||
<dt>Theme</dt><dd>Follows your system setting</dd>
|
||
<dt>Language</dt><dd>English</dd>
|
||
</dl>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Power</h2>
|
||
<div class="card">
|
||
<p class="lede">
|
||
Reboot or shut down the whole Furtka box. Takes a few seconds to
|
||
finish; the UI will reconnect itself after a reboot.
|
||
</p>
|
||
<div class="power-actions">
|
||
<button type="button" id="power-reboot" class="secondary">Reboot</button>
|
||
<button type="button" id="power-poweroff" class="danger">Shut down</button>
|
||
</div>
|
||
<p id="power-status" class="hint"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Coming next</h2>
|
||
<div class="coming">
|
||
<p class="hint">Controls we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
|
||
<a href="https://furtka.org/#planned">Change hostname</a>
|
||
<a href="https://furtka.org/#planned">Backup</a>
|
||
<a href="https://furtka.org/#planned">User accounts</a>
|
||
</div>
|
||
</section>
|
||
|
||
<footer>
|
||
<p>Furtka · <a href="https://furtka.org">furtka.org</a></p>
|
||
</footer>
|
||
</main>
|
||
|
||
<script>
|
||
// Logout button in the nav — same shape as /apps and / pages.
|
||
async function doLogout(ev) {
|
||
ev.preventDefault();
|
||
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
|
||
catch (e) { /* server may already be down */ }
|
||
window.location.href = '/login';
|
||
return false;
|
||
}
|
||
|
||
async function refresh() {
|
||
try {
|
||
const r = await fetch('/status.json', { cache: 'no-store' });
|
||
if (!r.ok) return;
|
||
const s = await r.json();
|
||
document.getElementById('set-hostname').textContent = s.hostname || '—';
|
||
document.getElementById('set-ip').textContent = s.ip_primary || '—';
|
||
document.getElementById('set-version').textContent = s.furtka_version || '—';
|
||
document.getElementById('set-kernel').textContent = s.kernel || '—';
|
||
document.getElementById('set-ram').textContent = s.ram_total || '—';
|
||
document.getElementById('set-docker').textContent = s.docker_version || '—';
|
||
document.getElementById('set-uptime').textContent = s.uptime || '—';
|
||
document.getElementById('upd-current').textContent = s.furtka_version || '—';
|
||
} catch (e) {
|
||
/* next tick will retry */
|
||
}
|
||
}
|
||
refresh();
|
||
setInterval(refresh, 15000);
|
||
|
||
// --- Furtka updates -----------------------------------------------
|
||
|
||
const STAGE_LABELS = {
|
||
downloading: 'Downloading release…',
|
||
verifying: 'Verifying signature…',
|
||
extracting: 'Unpacking update…',
|
||
swapping: 'Switching to new version…',
|
||
restarting: 'Restarting services…',
|
||
done: 'Update complete — reloading…',
|
||
rolled_back: 'Update failed, rolled back to the previous version',
|
||
rolled_back_manual: 'Rolled back manually',
|
||
};
|
||
|
||
let pollHandle = null;
|
||
let fallbackReloadHandle = null;
|
||
const statusEl = document.getElementById('update-status');
|
||
const checkBtn = document.getElementById('check-updates-btn');
|
||
const applyBtn = document.getElementById('apply-update-btn');
|
||
|
||
function setStatus(msg, isError = false) {
|
||
statusEl.textContent = msg;
|
||
statusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)';
|
||
}
|
||
|
||
checkBtn.addEventListener('click', async () => {
|
||
checkBtn.disabled = true;
|
||
const original = checkBtn.textContent;
|
||
checkBtn.textContent = 'Checking…';
|
||
setStatus('');
|
||
try {
|
||
const r = await fetch('/api/furtka/update/check', { method: 'POST' });
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
setStatus(data.error || `HTTP ${r.status}`, true);
|
||
return;
|
||
}
|
||
document.getElementById('upd-latest').textContent = data.latest || '—';
|
||
document.getElementById('upd-current').textContent = data.current || '—';
|
||
if (data.update_available) {
|
||
applyBtn.hidden = false;
|
||
applyBtn.textContent = `Update to ${data.latest}`;
|
||
setStatus(`Update available: ${data.current} → ${data.latest}`);
|
||
} else {
|
||
applyBtn.hidden = true;
|
||
setStatus('Already up to date');
|
||
}
|
||
} catch (e) {
|
||
setStatus(`Network error: ${e.message}`, true);
|
||
} finally {
|
||
checkBtn.disabled = false;
|
||
checkBtn.textContent = original;
|
||
}
|
||
});
|
||
|
||
applyBtn.addEventListener('click', async () => {
|
||
applyBtn.disabled = true;
|
||
checkBtn.disabled = true;
|
||
setStatus('Starting update…');
|
||
try {
|
||
const r = await fetch('/api/furtka/update/apply', { method: 'POST' });
|
||
const data = await r.json();
|
||
if (r.status === 409) {
|
||
setStatus('Another update is already running — watching it', true);
|
||
} else if (!r.ok) {
|
||
setStatus(data.error || `HTTP ${r.status}`, true);
|
||
applyBtn.disabled = false;
|
||
checkBtn.disabled = false;
|
||
return;
|
||
}
|
||
// Poll /update-state.json (served by Caddy, unaffected by the
|
||
// API restart the updater is about to trigger) every 2s.
|
||
pollHandle = setInterval(pollUpdateState, 2000);
|
||
// Fallback: reload regardless of whether polling observes 'done'.
|
||
// The mid-apply API restart can drop the poll connection before
|
||
// the terminal state is ever seen by this page.
|
||
fallbackReloadHandle = setTimeout(() => location.reload(), 45000);
|
||
} catch (e) {
|
||
setStatus(`Network error: ${e.message}`, true);
|
||
applyBtn.disabled = false;
|
||
checkBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// --- Local HTTPS --------------------------------------------------
|
||
|
||
const httpsFingerprintEl = document.getElementById('https-fingerprint');
|
||
const httpsReachableEl = document.getElementById('https-reachable');
|
||
const httpsHostEl = document.getElementById('https-host');
|
||
const httpsDownloadBtn = document.getElementById('https-download-btn');
|
||
const httpsForceWrap = document.getElementById('https-force-wrap');
|
||
const httpsForceHint = document.getElementById('https-force-hint');
|
||
const httpsForce = document.getElementById('https-force');
|
||
const httpsStatusEl = document.getElementById('https-status');
|
||
|
||
httpsHostEl.textContent = location.hostname;
|
||
|
||
httpsDownloadBtn.addEventListener('click', () => {
|
||
// Use an anchor with the download attr so the browser treats
|
||
// the cert as a download rather than rendering it.
|
||
const a = document.createElement('a');
|
||
a.href = '/rootCA.crt';
|
||
a.download = 'furtka-local-rootCA.crt';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
});
|
||
|
||
async function refreshHttpsStatus() {
|
||
try {
|
||
const r = await fetch('/api/furtka/https/status', { cache: 'no-store' });
|
||
if (!r.ok) return;
|
||
const s = await r.json();
|
||
httpsFingerprintEl.textContent = s.fingerprint_sha256 || 'waiting for Caddy…';
|
||
httpsDownloadBtn.disabled = !s.ca_available;
|
||
httpsForce.checked = !!s.force_https;
|
||
updateForceToggleVisibility(s);
|
||
} catch (e) {
|
||
/* next refresh will retry */
|
||
}
|
||
}
|
||
|
||
async function probeHttpsReachable() {
|
||
if (location.protocol === 'https:') {
|
||
httpsReachableEl.textContent = 'yes — you are on HTTPS now';
|
||
return true;
|
||
}
|
||
try {
|
||
// no-cors: we don't need the response body, just whether the
|
||
// TLS handshake + fetch succeed. Browsers reject on untrusted
|
||
// cert with a TypeError, which is exactly the signal we want.
|
||
await fetch('https://' + location.hostname + '/furtka.json',
|
||
{ cache: 'no-store', mode: 'no-cors' });
|
||
httpsReachableEl.textContent = 'yes — CA already trusted';
|
||
return true;
|
||
} catch (e) {
|
||
httpsReachableEl.textContent = 'no — install the CA first';
|
||
return false;
|
||
}
|
||
}
|
||
|
||
let httpsReachableCache = false;
|
||
function updateForceToggleVisibility(status) {
|
||
// Show the force-redirect toggle only when both:
|
||
// - Caddy's CA exists (otherwise there's no HTTPS to redirect to)
|
||
// - the current browser already trusts the cert (otherwise the
|
||
// user would lock themselves out of this very page)
|
||
const show = status.ca_available && httpsReachableCache;
|
||
httpsForceWrap.hidden = !show;
|
||
httpsForceHint.hidden = !show;
|
||
}
|
||
|
||
httpsForce.addEventListener('change', async () => {
|
||
httpsForce.disabled = true;
|
||
const desired = httpsForce.checked;
|
||
httpsStatusEl.textContent = desired
|
||
? 'Enabling HTTP→HTTPS redirect…'
|
||
: 'Disabling HTTP→HTTPS redirect…';
|
||
httpsStatusEl.style.color = 'var(--muted)';
|
||
try {
|
||
const r = await fetch('/api/furtka/https/force', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: desired }),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
httpsStatusEl.textContent = data.error || `HTTP ${r.status}`;
|
||
httpsStatusEl.style.color = 'var(--danger)';
|
||
httpsForce.checked = !desired;
|
||
} else {
|
||
httpsStatusEl.textContent = data.force_https
|
||
? 'Redirect on — new HTTP requests will jump to HTTPS.'
|
||
: 'Redirect off — HTTP serves the content directly.';
|
||
}
|
||
} catch (e) {
|
||
httpsStatusEl.textContent = `Network error: ${e.message}`;
|
||
httpsStatusEl.style.color = 'var(--danger)';
|
||
httpsForce.checked = !desired;
|
||
} finally {
|
||
httpsForce.disabled = false;
|
||
}
|
||
});
|
||
|
||
(async () => {
|
||
httpsReachableCache = await probeHttpsReachable();
|
||
await refreshHttpsStatus();
|
||
})();
|
||
|
||
// --- Furtka Gateway -------------------------------------------------
|
||
|
||
const CONNECT_STAGE_LABELS = {
|
||
registering: 'Registering with the gateway…',
|
||
starting_tunnel: 'Starting the WireGuard tunnel…',
|
||
done: 'Connected.',
|
||
};
|
||
|
||
const gatewayDisconnectedEl = document.getElementById('gateway-disconnected');
|
||
const gatewayConnectedEl = document.getElementById('gateway-connected');
|
||
const gwConnectedUrlEl = document.getElementById('gw-connected-url');
|
||
const gwTunnelStatusEl = document.getElementById('gw-tunnel-status');
|
||
const gatewayStatusEl = document.getElementById('gateway-status');
|
||
const gatewayConnectBtn = document.getElementById('gateway-connect-btn');
|
||
const gatewayDisconnectBtn = document.getElementById('gateway-disconnect-btn');
|
||
let gatewayPollHandle = null;
|
||
|
||
function setGatewayStatus(msg, isError = false) {
|
||
gatewayStatusEl.textContent = msg;
|
||
gatewayStatusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)';
|
||
}
|
||
|
||
function describeTunnel(tunnel) {
|
||
if (!tunnel || !tunnel.configured) return 'not configured';
|
||
return tunnel.latest_handshake ? `up (last handshake ${tunnel.latest_handshake})` : 'configured, no handshake yet';
|
||
}
|
||
|
||
async function refreshGatewayStatus() {
|
||
try {
|
||
const r = await fetch('/api/gateway/status', { cache: 'no-store' });
|
||
// Sessions are in-memory, so a furtka-api restart logs us
|
||
// out. Both panels start hidden — never claim "not
|
||
// connected" when we simply couldn't ask.
|
||
if (r.status === 401) {
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
if (!r.ok) return;
|
||
const s = await r.json();
|
||
gatewayDisconnectedEl.hidden = !!s.connected;
|
||
gatewayConnectedEl.hidden = !s.connected;
|
||
if (s.connected) {
|
||
gwConnectedUrlEl.textContent = s.gateway_url;
|
||
gwTunnelStatusEl.textContent = describeTunnel(s.tunnel);
|
||
}
|
||
} catch (e) {
|
||
/* next refresh will retry */
|
||
}
|
||
}
|
||
refreshGatewayStatus();
|
||
|
||
gatewayConnectBtn.addEventListener('click', async () => {
|
||
const gateway_url = document.getElementById('gw-url').value.trim();
|
||
const registration_token = document.getElementById('gw-token').value;
|
||
const box_name = document.getElementById('gw-name').value.trim();
|
||
if (!gateway_url || !registration_token || !box_name) {
|
||
setGatewayStatus('Gateway URL, registration token, and box name are all required.', true);
|
||
return;
|
||
}
|
||
gatewayConnectBtn.disabled = true;
|
||
setGatewayStatus('Connecting…');
|
||
try {
|
||
const r = await fetch('/api/gateway/connect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ gateway_url, registration_token, box_name }),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
|
||
gatewayConnectBtn.disabled = false;
|
||
return;
|
||
}
|
||
gatewayPollHandle = setInterval(pollGatewayConnectState, 2000);
|
||
} catch (e) {
|
||
setGatewayStatus(`Network error: ${e.message}`, true);
|
||
gatewayConnectBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
async function pollGatewayConnectState() {
|
||
try {
|
||
const r = await fetch('/api/gateway/connect/status', { cache: 'no-store' });
|
||
if (!r.ok) return;
|
||
const s = await r.json();
|
||
const label = CONNECT_STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
|
||
setGatewayStatus(label, s.stage === 'error');
|
||
if (s.stage === 'done') {
|
||
clearInterval(gatewayPollHandle);
|
||
gatewayConnectBtn.disabled = false;
|
||
await refreshGatewayStatus();
|
||
} else if (s.stage === 'error') {
|
||
clearInterval(gatewayPollHandle);
|
||
gatewayConnectBtn.disabled = false;
|
||
if (s.error) setGatewayStatus(`${label} — ${s.error}`, true);
|
||
}
|
||
} catch (e) {
|
||
/* keep polling; transient blip expected */
|
||
}
|
||
}
|
||
|
||
gatewayDisconnectBtn.addEventListener('click', async () => {
|
||
if (!confirm('Disconnect from the gateway? Any apps currently exposed to the internet will stop being reachable.')) return;
|
||
gatewayDisconnectBtn.disabled = true;
|
||
setGatewayStatus('Disconnecting…');
|
||
try {
|
||
const r = await fetch('/api/gateway/disconnect', { method: 'POST' });
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
|
||
return;
|
||
}
|
||
setGatewayStatus('Disconnected.');
|
||
await refreshGatewayStatus();
|
||
} catch (e) {
|
||
setGatewayStatus(`Network error: ${e.message}`, true);
|
||
} finally {
|
||
gatewayDisconnectBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
async function pollUpdateState() {
|
||
try {
|
||
const r = await fetch('/update-state.json', { cache: 'no-store' });
|
||
if (!r.ok) return;
|
||
const s = await r.json();
|
||
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
|
||
setStatus(label, s.stage === 'rolled_back');
|
||
if (s.stage === 'done') {
|
||
clearInterval(pollHandle);
|
||
clearTimeout(fallbackReloadHandle);
|
||
setTimeout(() => location.reload(), 5000);
|
||
} else if (s.stage === 'rolled_back') {
|
||
clearInterval(pollHandle);
|
||
clearTimeout(fallbackReloadHandle);
|
||
if (s.reason) {
|
||
setStatus(`${label} — ${s.reason}`, true);
|
||
}
|
||
applyBtn.disabled = false;
|
||
checkBtn.disabled = false;
|
||
}
|
||
} catch (e) {
|
||
/* keep polling; restart blip expected */
|
||
}
|
||
}
|
||
|
||
// Power buttons: confirm, POST, then swap the whole card into a
|
||
// "going down" state so the user doesn't keep clicking. After a
|
||
// reboot we try to reconnect after ~45s; for shutdown we just
|
||
// tell the user the box is off — no auto-reconnect attempt.
|
||
const powerStatusEl = document.getElementById('power-status');
|
||
const rebootBtn = document.getElementById('power-reboot');
|
||
const poweroffBtn = document.getElementById('power-poweroff');
|
||
|
||
function setPowerStatus(msg, tone = 'muted') {
|
||
powerStatusEl.textContent = msg;
|
||
powerStatusEl.style.color =
|
||
tone === 'error' ? 'var(--danger)' : 'var(--muted)';
|
||
}
|
||
|
||
async function triggerPower(action, confirmMsg, inflightLabel) {
|
||
if (!confirm(confirmMsg)) return;
|
||
rebootBtn.disabled = true;
|
||
poweroffBtn.disabled = true;
|
||
setPowerStatus(inflightLabel);
|
||
try {
|
||
const r = await fetch('/api/furtka/power', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action }),
|
||
});
|
||
if (!r.ok) {
|
||
const data = await r.json().catch(() => ({}));
|
||
setPowerStatus(data.error || `HTTP ${r.status}`, 'error');
|
||
rebootBtn.disabled = false;
|
||
poweroffBtn.disabled = false;
|
||
return;
|
||
}
|
||
if (action === 'reboot') {
|
||
setPowerStatus('Rebooting… this page will reload when the box is back.');
|
||
// Try reconnecting after a generous delay. archinstall
|
||
// + boot + services typically takes 30–45 s; give it 30
|
||
// before the first poke so we don't just spin against
|
||
// a down kernel.
|
||
setTimeout(pollForReconnect, 30000);
|
||
} else {
|
||
setPowerStatus(
|
||
'Shutdown scheduled. Press the physical power button to turn it back on.'
|
||
);
|
||
}
|
||
} catch (e) {
|
||
setPowerStatus(`Network error: ${e.message}`, 'error');
|
||
rebootBtn.disabled = false;
|
||
poweroffBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function pollForReconnect() {
|
||
// Fetch a tiny static file; when it comes back 200 the box is up.
|
||
try {
|
||
const r = await fetch('/furtka.json', { cache: 'no-store' });
|
||
if (r.ok) {
|
||
setPowerStatus('Back up — reloading…');
|
||
setTimeout(() => location.reload(), 1500);
|
||
return;
|
||
}
|
||
} catch (e) { /* still down */ }
|
||
setTimeout(pollForReconnect, 3000);
|
||
}
|
||
|
||
rebootBtn.addEventListener('click', () =>
|
||
triggerPower(
|
||
'reboot',
|
||
"Wirklich neu starten? Die Box ist für ~30 Sekunden nicht erreichbar.",
|
||
'Rebooting…'
|
||
)
|
||
);
|
||
poweroffBtn.addEventListener('click', () =>
|
||
triggerPower(
|
||
'poweroff',
|
||
"Wirklich ausschalten? Du kannst die Box erst wieder starten, wenn du den physischen Power-Knopf drückst.",
|
||
'Shutting down…'
|
||
)
|
||
);
|
||
</script>
|
||
</body>
|
||
</html>
|