refactor(webinstaller): extract inline payload constants to furtka/assets/
Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd-
unit payload that used to live as a triple-quoted string constant inside
webinstaller/app.py now lives as a real file under furtka/assets/:
furtka/assets/Caddyfile
furtka/assets/VERSION (new — matches pyproject.toml)
furtka/assets/www/{index.html, settings/index.html, style.css, status.json}
furtka/assets/bin/{furtka-status, furtka-welcome}
furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service
furtka/assets/systemd/furtka-status.timer
The installer now pulls each file from disk via _read_asset(). Byte-for-
byte identical output at install time — a fresh-ISO install should land
the same files in the same places with the same contents, verified by
tests/test_webinstaller_assets.py which reconstructs each base64 blob
and asserts equality against the on-disk asset.
iso/build.sh also copies furtka/assets/ next to the webinstaller source
at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds
them with a "next to me" lookup. In dev the same function walks two
levels up to the repo copy, so pytest works without any env vars.
furtka-status.sh drops the /etc/furtka/version TODO — it now reads
/opt/furtka/VERSION directly, which Slice 1b will upgrade to
/opt/furtka/current/VERSION once the symlink layout lands.
_FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline;
it's tiny and not asset-shaped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
|
|
|
<!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>
|
|
|
|
|
</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>
|
|
|
|
|
|
2026-04-16 13:44:34 +02:00
|
|
|
<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>
|
|
|
|
|
|
refactor(webinstaller): extract inline payload constants to furtka/assets/
Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd-
unit payload that used to live as a triple-quoted string constant inside
webinstaller/app.py now lives as a real file under furtka/assets/:
furtka/assets/Caddyfile
furtka/assets/VERSION (new — matches pyproject.toml)
furtka/assets/www/{index.html, settings/index.html, style.css, status.json}
furtka/assets/bin/{furtka-status, furtka-welcome}
furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service
furtka/assets/systemd/furtka-status.timer
The installer now pulls each file from disk via _read_asset(). Byte-for-
byte identical output at install time — a fresh-ISO install should land
the same files in the same places with the same contents, verified by
tests/test_webinstaller_assets.py which reconstructs each base64 blob
and asserts equality against the on-disk asset.
iso/build.sh also copies furtka/assets/ next to the webinstaller source
at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds
them with a "next to me" lookup. In dev the same function walks two
levels up to the repo copy, so pytest works without any env vars.
furtka-status.sh drops the /etc/furtka/version TODO — it now reads
/opt/furtka/VERSION directly, which Slice 1b will upgrade to
/opt/furtka/current/VERSION once the symlink layout lands.
_FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline;
it's tiny and not asset-shaped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
|
|
|
<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>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">Reboot</a>
|
|
|
|
|
<a href="https://furtka.org/#planned">Shut down</a>
|
|
|
|
|
<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>
|
|
|
|
|
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 || '—';
|
2026-04-16 13:44:34 +02:00
|
|
|
document.getElementById('upd-current').textContent = s.furtka_version || '—';
|
refactor(webinstaller): extract inline payload constants to furtka/assets/
Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd-
unit payload that used to live as a triple-quoted string constant inside
webinstaller/app.py now lives as a real file under furtka/assets/:
furtka/assets/Caddyfile
furtka/assets/VERSION (new — matches pyproject.toml)
furtka/assets/www/{index.html, settings/index.html, style.css, status.json}
furtka/assets/bin/{furtka-status, furtka-welcome}
furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service
furtka/assets/systemd/furtka-status.timer
The installer now pulls each file from disk via _read_asset(). Byte-for-
byte identical output at install time — a fresh-ISO install should land
the same files in the same places with the same contents, verified by
tests/test_webinstaller_assets.py which reconstructs each base64 blob
and asserts equality against the on-disk asset.
iso/build.sh also copies furtka/assets/ next to the webinstaller source
at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds
them with a "next to me" lookup. In dev the same function walks two
levels up to the repo copy, so pytest works without any env vars.
furtka-status.sh drops the /etc/furtka/version TODO — it now reads
/opt/furtka/VERSION directly, which Slice 1b will upgrade to
/opt/furtka/current/VERSION once the symlink layout lands.
_FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline;
it's tiny and not asset-shaped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
/* next tick will retry */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
refresh();
|
|
|
|
|
setInterval(refresh, 15000);
|
2026-04-16 13:44:34 +02:00
|
|
|
|
|
|
|
|
// --- 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;
|
2026-04-17 09:22:34 +02:00
|
|
|
let fallbackReloadHandle = null;
|
2026-04-16 13:44:34 +02:00
|
|
|
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 || '—';
|
2026-04-17 09:22:34 +02:00
|
|
|
document.getElementById('upd-current').textContent = data.current || '—';
|
2026-04-16 13:44:34 +02:00
|
|
|
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);
|
2026-04-17 09:22:34 +02:00
|
|
|
// 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);
|
2026-04-16 13:44:34 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
setStatus(`Network error: ${e.message}`, true);
|
|
|
|
|
applyBtn.disabled = false;
|
|
|
|
|
checkBtn.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);
|
2026-04-17 09:22:34 +02:00
|
|
|
clearTimeout(fallbackReloadHandle);
|
2026-04-16 13:44:34 +02:00
|
|
|
setTimeout(() => location.reload(), 5000);
|
|
|
|
|
} else if (s.stage === 'rolled_back') {
|
|
|
|
|
clearInterval(pollHandle);
|
2026-04-17 09:22:34 +02:00
|
|
|
clearTimeout(fallbackReloadHandle);
|
2026-04-16 13:44:34 +02:00
|
|
|
if (s.reason) {
|
|
|
|
|
setStatus(`${label} — ${s.reason}`, true);
|
|
|
|
|
}
|
|
|
|
|
applyBtn.disabled = false;
|
|
|
|
|
checkBtn.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
/* keep polling; restart blip expected */
|
|
|
|
|
}
|
|
|
|
|
}
|
refactor(webinstaller): extract inline payload constants to furtka/assets/
Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd-
unit payload that used to live as a triple-quoted string constant inside
webinstaller/app.py now lives as a real file under furtka/assets/:
furtka/assets/Caddyfile
furtka/assets/VERSION (new — matches pyproject.toml)
furtka/assets/www/{index.html, settings/index.html, style.css, status.json}
furtka/assets/bin/{furtka-status, furtka-welcome}
furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service
furtka/assets/systemd/furtka-status.timer
The installer now pulls each file from disk via _read_asset(). Byte-for-
byte identical output at install time — a fresh-ISO install should land
the same files in the same places with the same contents, verified by
tests/test_webinstaller_assets.py which reconstructs each base64 blob
and asserts equality against the on-disk asset.
iso/build.sh also copies furtka/assets/ next to the webinstaller source
at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds
them with a "next to me" lookup. In dev the same function walks two
levels up to the repo copy, so pytest works without any env vars.
furtka-status.sh drops the /etc/furtka/version TODO — it now reads
/opt/furtka/VERSION directly, which Slice 1b will upgrade to
/opt/furtka/current/VERSION once the symlink layout lands.
_FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline;
it's tiny and not asset-shaped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|