furtka/assets/www/settings/index.html
Daniel Maksymilian Syrnicki 91aa33b891
All checks were successful
CI / lint (pull_request) Successful in 30s
CI / validate-json (pull_request) Successful in 24s
CI / markdown-links (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m47s
CI / lint (push) Successful in 28s
CI / test (push) Successful in 1m56s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 21s
Deploy site / deploy (push) Successful in 5s
Build ISO / build-iso (push) Successful in 20m27s
feat(brand): Mark M is the Furtka logo — website, installer, box UI, console, boot splash
The logo vote on furtka.org closed with M 29 · R 13 (42 votes).

- website/tools/brand-assets.py generates every brand file from the
  logo-assets.py geometry: favicons (small-size tier, accent with a
  dark-mode variant) for website / webinstaller / on-box UI, 180 px
  apple-touch-icon, 1200x630 default share card, 640x480 syslinux splash,
  and docs/brand/ (mark, small mark, lockups, 20-column console art)
- website: header mark, favicon, touch icon, default og:image; /logo/ now
  shows the final result (static counts, winner highlighted, no voting
  UI or poll.js); homepage link reads 'meet our new logo'
- webinstaller: M in the header + tab icon
- on-box UI: .brand mark via CSS mask in brand red (not the UI's green),
  favicon on every page, brand line on the login page
- console: M art above the text of /etc/issue on the installed box
  (furtka-welcome) and the live ISO (furtka-update-issue)
- ISO: build.sh swaps releng's Arch splash for the Furtka one
- README shows the lockup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 15:08:49 +02:00

459 lines
20 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>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>
<a href="https://furtka.org/#planned">Remote access</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();
})();
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>