furtka/assets/www/settings/index.html
Robert Syrnicki 2f7a02f1d1
Some checks failed
CI / lint (pull_request) Failing after 28s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 21s
CI / test (pull_request) Failing after 1m45s
Merge remote-tracking branch 'origin/main' into dev_gateway
# Conflicts:
#	tests/test_api.py
2026-10-07 16:11:54 +02:00

618 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<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>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// 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',
error: 'Update failed — nothing was changed',
};
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}`;
const failed = s.stage === 'rolled_back' || s.stage === 'error';
setStatus(label, failed);
if (s.stage === 'done') {
clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle);
setTimeout(() => location.reload(), 5000);
} else if (failed) {
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>