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>
This commit is contained in:
parent
9bfbf209b6
commit
df08938d7e
16 changed files with 961 additions and 833 deletions
20
furtka/assets/Caddyfile
Normal file
20
furtka/assets/Caddyfile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Serves the Furtka landing page + status.json on :80. Static for the
|
||||||
|
# landing page; /apps and /api are reverse-proxied to the local resource-
|
||||||
|
# manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth come
|
||||||
|
# later when Authentik is wired in.
|
||||||
|
:80 {
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy localhost:7000
|
||||||
|
}
|
||||||
|
handle /apps* {
|
||||||
|
reverse_proxy localhost:7000
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
root * /srv/furtka/www
|
||||||
|
file_server
|
||||||
|
encode gzip
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
}
|
||||||
|
}
|
||||||
1
furtka/assets/VERSION
Normal file
1
furtka/assets/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
26.0-alpha
|
||||||
38
furtka/assets/bin/furtka-status
Normal file
38
furtka/assets/bin/furtka-status
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Writes /srv/furtka/www/status.json with current system stats. Fired by
|
||||||
|
# furtka-status.timer every 30s; also runs once 10s after boot.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
out=/srv/furtka/www/status.json
|
||||||
|
tmp=$(mktemp)
|
||||||
|
|
||||||
|
hostname=$(cat /etc/hostname)
|
||||||
|
uptime=$(uptime -p 2>/dev/null | sed 's/^up //' || echo unknown)
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
docker_version=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || echo unavailable)
|
||||||
|
else
|
||||||
|
docker_version=unavailable
|
||||||
|
fi
|
||||||
|
disk_free=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free of " $2}' || echo unknown)
|
||||||
|
ip_primary=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1 || true)
|
||||||
|
kernel=$(uname -r 2>/dev/null || echo unknown)
|
||||||
|
ram_total=$(free -h --si 2>/dev/null | awk '/^Mem:/ {print $2}' || echo unknown)
|
||||||
|
furtka_version=$(cat /opt/furtka/VERSION 2>/dev/null || echo dev)
|
||||||
|
updated_at=$(date -Iseconds)
|
||||||
|
|
||||||
|
cat > "$tmp" <<EOF
|
||||||
|
{
|
||||||
|
"hostname": "$hostname",
|
||||||
|
"uptime": "$uptime",
|
||||||
|
"docker_version": "$docker_version",
|
||||||
|
"disk_free": "$disk_free",
|
||||||
|
"ip_primary": "$ip_primary",
|
||||||
|
"kernel": "$kernel",
|
||||||
|
"ram_total": "$ram_total",
|
||||||
|
"furtka_version": "$furtka_version",
|
||||||
|
"updated_at": "$updated_at"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mv "$tmp" "$out"
|
||||||
|
chmod 644 "$out"
|
||||||
22
furtka/assets/bin/furtka-welcome
Normal file
22
furtka/assets/bin/furtka-welcome
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Regenerates /etc/issue on the installed system so the console tells the
|
||||||
|
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
hostname=$(cat /etc/hostname)
|
||||||
|
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo " Furtka is ready."
|
||||||
|
echo
|
||||||
|
echo " Open in a browser on another device on your network:"
|
||||||
|
echo
|
||||||
|
echo " http://${hostname}.local (easy — try this first)"
|
||||||
|
if [ -n "$ip" ]; then
|
||||||
|
echo " http://${ip} (fallback if the first doesn't work)"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
} > /etc/issue
|
||||||
|
|
||||||
|
agetty --reload 2>/dev/null || true
|
||||||
14
furtka/assets/systemd/furtka-api.service
Normal file
14
furtka/assets/systemd/furtka-api.service
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Furtka resource-manager HTTP API + UI
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target furtka-reconcile.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/furtka serve --host 127.0.0.1 --port 7000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
13
furtka/assets/systemd/furtka-reconcile.service
Normal file
13
furtka/assets/systemd/furtka-reconcile.service
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Furtka app reconciler (boot-scan)
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/furtka reconcile
|
||||||
|
RemainAfterExit=no
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
7
furtka/assets/systemd/furtka-status.service
Normal file
7
furtka/assets/systemd/furtka-status.service
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Refresh Furtka system status JSON
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/furtka-status
|
||||||
10
furtka/assets/systemd/furtka-status.timer
Normal file
10
furtka/assets/systemd/furtka-status.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Refresh Furtka system status every 30s
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=10s
|
||||||
|
OnUnitActiveSec=30s
|
||||||
|
AccuracySec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
12
furtka/assets/systemd/furtka-welcome.service
Normal file
12
furtka/assets/systemd/furtka-welcome.service
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Furtka console welcome banner
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/furtka-welcome
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
136
furtka/assets/www/index.html
Normal file
136
furtka/assets/www/index.html
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>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="/" aria-current="page">Home</a>
|
||||||
|
<a href="/apps">Apps</a>
|
||||||
|
<a href="/settings/">Settings</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<header>
|
||||||
|
<h1>Welcome to Furtka</h1>
|
||||||
|
<p class="lead">Your home server is ready.</p>
|
||||||
|
<p class="host">Running on <code>__HOSTNAME__</code></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Your apps</h2>
|
||||||
|
<div id="apps-section" class="grid-apps">
|
||||||
|
<a class="app-tile" href="/apps"><span class="name">Loading…</span></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>System status</h2>
|
||||||
|
<div class="tiles">
|
||||||
|
<div class="tile">
|
||||||
|
<span class="label">Uptime</span>
|
||||||
|
<span class="value" id="uptime">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="label">Docker</span>
|
||||||
|
<span class="value" id="docker">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="label">Free disk</span>
|
||||||
|
<span class="value" id="disk">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="updated">Updated <span id="updated">—</span></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Coming next</h2>
|
||||||
|
<div class="coming">
|
||||||
|
<p class="hint">Features we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
|
||||||
|
<a href="https://furtka.org/#planned">Photos</a>
|
||||||
|
<a href="https://furtka.org/#planned">Smart home</a>
|
||||||
|
<a href="https://furtka.org/#planned">Media streaming</a>
|
||||||
|
<a href="https://furtka.org/#planned">Multiple boxes</a>
|
||||||
|
<a href="https://furtka.org/#planned">Secure link</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>
|
||||||
|
const HOSTNAME = "__HOSTNAME__";
|
||||||
|
const FALLBACK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryAction(app) {
|
||||||
|
// Only fileshare has a direct "open" link today. Future apps with
|
||||||
|
// HTTP endpoints would surface a URL here; everything else falls
|
||||||
|
// back to the /apps manage page.
|
||||||
|
if (app.name === 'fileshare') {
|
||||||
|
return { href: `smb://${HOSTNAME}.local/files`, label: 'Open files' };
|
||||||
|
}
|
||||||
|
return { href: '/apps', label: 'Manage →' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderApps() {
|
||||||
|
const target = document.getElementById('apps-section');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/apps', { cache: 'no-store' });
|
||||||
|
if (!r.ok) throw new Error('api');
|
||||||
|
const apps = (await r.json()).filter(a => a.ok !== false);
|
||||||
|
if (!apps.length) {
|
||||||
|
target.innerHTML = '<a class="app-tile" href="/apps">' +
|
||||||
|
'<span class="name">No apps yet</span>' +
|
||||||
|
'<span class="cta">Install your first app →</span></a>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = apps.map(a => {
|
||||||
|
const icon = a.icon_svg || FALLBACK_ICON;
|
||||||
|
const { href, label } = primaryAction(a);
|
||||||
|
return `<a class="app-tile" href="${esc(href)}">
|
||||||
|
<div class="icon">${icon}</div>
|
||||||
|
<span class="name">${esc(a.display_name || a.name)}</span>
|
||||||
|
<span class="cta">${esc(label)}</span>
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
target.innerHTML = '<a class="app-tile" href="/apps">' +
|
||||||
|
'<span class="name">Manage apps</span>' +
|
||||||
|
'<span class="cta">Open →</span></a>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/status.json', { cache: 'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const s = await r.json();
|
||||||
|
document.getElementById('uptime').textContent = s.uptime || '—';
|
||||||
|
document.getElementById('docker').textContent = s.docker_version || '—';
|
||||||
|
document.getElementById('disk').textContent = s.disk_free || '—';
|
||||||
|
document.getElementById('updated').textContent = s.updated_at || '—';
|
||||||
|
} catch (e) {
|
||||||
|
/* next tick will retry */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderApps();
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
furtka/assets/www/settings/index.html
Normal file
87
furtka/assets/www/settings/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!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>
|
||||||
|
|
||||||
|
<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 || '—';
|
||||||
|
} catch (e) {
|
||||||
|
/* next tick will retry */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
furtka/assets/www/status.json
Normal file
11
furtka/assets/www/status.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"hostname": "",
|
||||||
|
"uptime": "starting…",
|
||||||
|
"docker_version": "starting…",
|
||||||
|
"disk_free": "starting…",
|
||||||
|
"ip_primary": "",
|
||||||
|
"kernel": "",
|
||||||
|
"ram_total": "",
|
||||||
|
"furtka_version": "",
|
||||||
|
"updated_at": ""
|
||||||
|
}
|
||||||
406
furtka/assets/www/style.css
Normal file
406
furtka/assets/www/style.css
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
/* Furtka on-box design system. Served by Caddy at /style.css,
|
||||||
|
consumed by the landing page AND the resource-manager /apps
|
||||||
|
page. One source of truth for tokens + components. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--fg: #e8eaed;
|
||||||
|
--muted: #9aa0a6;
|
||||||
|
--accent: #6ee7b7;
|
||||||
|
--accent-soft: rgba(110, 231, 183, 0.12);
|
||||||
|
--card: #1a1d24;
|
||||||
|
--card-hover: #222530;
|
||||||
|
--border: #2a2d34;
|
||||||
|
--warn: #4a3030;
|
||||||
|
--warn-fg: #fed;
|
||||||
|
--danger: #f08080;
|
||||||
|
|
||||||
|
--r-sm: 4px;
|
||||||
|
--r-md: 8px;
|
||||||
|
--r-lg: 12px;
|
||||||
|
--r-pill: 999px;
|
||||||
|
|
||||||
|
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--ring: 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f7f6f3;
|
||||||
|
--fg: #17181c;
|
||||||
|
--muted: #5e6066;
|
||||||
|
--accent: #0f8a5f;
|
||||||
|
--accent-soft: rgba(15, 138, 95, 0.12);
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-hover: #f0efeb;
|
||||||
|
--border: #e3e1dc;
|
||||||
|
--warn: #fde2d3;
|
||||||
|
--warn-fg: #5a2a10;
|
||||||
|
--danger: #c03a28;
|
||||||
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared page container — both landing and /apps wrap content in
|
||||||
|
<main class="wrap"> so sizing + padding stay consistent. */
|
||||||
|
.wrap { max-width: 780px; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; }
|
||||||
|
|
||||||
|
/* Top nav — persistent across pages (Jakob's Law). */
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.brand::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.nav-links { display: flex; gap: 0.25rem; }
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--fg); }
|
||||||
|
.nav-links a[aria-current="page"] {
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Landing page ---------------------------------------------- */
|
||||||
|
header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; }
|
||||||
|
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
|
||||||
|
.host { color: var(--muted); margin: 0 0 3rem; }
|
||||||
|
.host code {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
section h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
}
|
||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.tile .label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.tile .value { font-size: 1.25rem; margin-top: 0.5rem; }
|
||||||
|
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
|
||||||
|
.soon {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
margin-top: 4rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
footer a { color: var(--accent); }
|
||||||
|
|
||||||
|
/* -- Apps page ------------------------------------------------- */
|
||||||
|
h1 { font-size: 2rem; margin: 0; }
|
||||||
|
h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 2rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.lede { color: var(--muted); margin: 0.25rem 0 1rem; }
|
||||||
|
.warn {
|
||||||
|
background: var(--warn);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
color: var(--warn-fg);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.app .left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.meta { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.name { font-weight: 600; font-size: 1.05rem; }
|
||||||
|
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
|
||||||
|
.desc {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
button.danger { background: var(--danger); color: #fff; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: wait; }
|
||||||
|
button:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||||
|
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
|
||||||
|
pre {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
details.log-details {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
details.log-details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
details.log-details[open] > summary { color: var(--fg); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.modal-backdrop.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; }
|
||||||
|
.modal .long {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.field { margin-bottom: 1rem; }
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; }
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||||
|
.field .req { color: var(--danger); margin-left: 0.25rem; }
|
||||||
|
.modal .error {
|
||||||
|
background: var(--warn);
|
||||||
|
color: var(--warn-fg);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.modal .error.show { display: block; }
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Shared primitives for later slices ------------------------ */
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.chip-muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.card + .card { margin-top: 1rem; }
|
||||||
|
.card h3 { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
column-gap: 1.25rem;
|
||||||
|
row-gap: 0.4rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.kv dt { color: var(--muted); }
|
||||||
|
.kv dd { margin: 0; color: var(--fg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
|
||||||
|
.coming {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.coming a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.coming a:hover { color: var(--fg); border-color: var(--accent); }
|
||||||
|
.coming .hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-apps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.app-tile {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--fg);
|
||||||
|
transition: border-color 120ms, background 120ms;
|
||||||
|
}
|
||||||
|
.app-tile:hover { border-color: var(--accent); background: var(--card-hover); }
|
||||||
|
.app-tile .icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.app-tile .icon svg { width: 100%; height: 100%; }
|
||||||
|
.app-tile .name { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.app-tile .cta { color: var(--accent); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* Icon slot inside a /apps row. The app icon inherits currentColor
|
||||||
|
so a folder path rendered with fill="currentColor" picks up the
|
||||||
|
accent, while a nested <path> using stroke="var(--accent)" still
|
||||||
|
gets the brand color. */
|
||||||
|
.app-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.app-icon svg { width: 36px; height: 36px; }
|
||||||
|
|
@ -74,6 +74,9 @@ sed -i "/--id 'archlinux'/s/menuentry \"Furtka Live Installer/menuentry \"(Recom
|
||||||
|
|
||||||
mkdir -p "$PROFILE_WORK/airootfs/opt/furtka"
|
mkdir -p "$PROFILE_WORK/airootfs/opt/furtka"
|
||||||
cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/"
|
cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/"
|
||||||
|
# Ship the post-install asset tree (HTML, CSS, systemd units, scripts, …)
|
||||||
|
# next to webinstaller/app.py so _resolve_assets_dir() finds it at runtime.
|
||||||
|
cp -a "$REPO_ROOT/furtka/assets" "$PROFILE_WORK/airootfs/opt/furtka/assets"
|
||||||
rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__"
|
rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__"
|
||||||
|
|
||||||
# Pack the resource manager (furtka/ Python package + bundled apps/) as a
|
# Pack the resource manager (furtka/ Python package + bundled apps/) as a
|
||||||
|
|
|
||||||
98
tests/test_webinstaller_assets.py
Normal file
98
tests/test_webinstaller_assets.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Asset sourcing tests for webinstaller.
|
||||||
|
|
||||||
|
Slice 1a of the self-update refactor moved every inline HTML/CSS/script/unit
|
||||||
|
file payload out of webinstaller/app.py and into furtka/assets/. These tests
|
||||||
|
lock the new contract: _post_install_commands() and _resource_manager_commands()
|
||||||
|
must write files whose content is byte-equal to the on-disk asset. If the
|
||||||
|
asset tree drifts or a constant sneaks back in, a test breaks loudly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "webinstaller"))
|
||||||
|
import app # noqa: E402
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ASSETS = REPO_ROOT / "furtka" / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
# (install target path, asset path under furtka/assets/)
|
||||||
|
ASSET_TARGETS = [
|
||||||
|
("/etc/caddy/Caddyfile", "Caddyfile"),
|
||||||
|
("/srv/furtka/www/index.html", "www/index.html"),
|
||||||
|
("/srv/furtka/www/settings/index.html", "www/settings/index.html"),
|
||||||
|
("/srv/furtka/www/style.css", "www/style.css"),
|
||||||
|
("/srv/furtka/www/status.json", "www/status.json"),
|
||||||
|
("/usr/local/bin/furtka-status", "bin/furtka-status"),
|
||||||
|
("/usr/local/bin/furtka-welcome", "bin/furtka-welcome"),
|
||||||
|
("/etc/systemd/system/furtka-status.service", "systemd/furtka-status.service"),
|
||||||
|
("/etc/systemd/system/furtka-status.timer", "systemd/furtka-status.timer"),
|
||||||
|
("/etc/systemd/system/furtka-welcome.service", "systemd/furtka-welcome.service"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_written_content(cmd, target):
|
||||||
|
"""Pull the base64 payload back out of a _write_file_cmd() shell string.
|
||||||
|
|
||||||
|
Shape: `mkdir -p <parent> && printf %s <b64> | base64 -d > <target>[ && chmod ...]`.
|
||||||
|
"""
|
||||||
|
match = re.search(r"printf %s (\S+) \| base64 -d > " + re.escape(target), cmd)
|
||||||
|
assert match, f"cmd didn't look like a write of {target}: {cmd[:100]}"
|
||||||
|
return base64.b64decode(match.group(1)).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def install_cmds():
|
||||||
|
return app._post_install_commands("testhost")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("target,asset_relpath", ASSET_TARGETS)
|
||||||
|
def test_post_install_writes_asset_from_disk(install_cmds, target, asset_relpath):
|
||||||
|
expected = (ASSETS / asset_relpath).read_text(encoding="utf-8")
|
||||||
|
# /srv/furtka/www/index.html carries a one-off hostname sed *after* the
|
||||||
|
# write; the bytes written match the asset verbatim.
|
||||||
|
if target == "/srv/furtka/www/index.html":
|
||||||
|
assert "__HOSTNAME__" in expected # slice-1a invariant: sed still applies
|
||||||
|
matching = [c for c in install_cmds if f" > {target}" in c]
|
||||||
|
assert matching, f"no command writes {target}"
|
||||||
|
assert _extract_written_content(matching[0], target) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_asset_raises_for_missing_file():
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
app._read_asset("does/not/exist.html")
|
||||||
|
|
||||||
|
|
||||||
|
def test_assets_dir_resolves_to_repo_tree():
|
||||||
|
# The resolved path must point into the repo's furtka/assets — sanity
|
||||||
|
# check that the "two levels up from webinstaller/app.py" math is correct.
|
||||||
|
assert app._ASSETS_DIR == ASSETS
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_manager_commands_use_asset_units(monkeypatch, tmp_path):
|
||||||
|
# Create a fake payload so _resource_manager_commands doesn't bail out.
|
||||||
|
fake = tmp_path / "payload.tar.gz"
|
||||||
|
fake.write_bytes(b"not a real tarball")
|
||||||
|
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
|
||||||
|
cmds = app._resource_manager_commands()
|
||||||
|
# Expect reconcile + api unit files to be sourced from furtka/assets/systemd/.
|
||||||
|
for unit in ("furtka-reconcile.service", "furtka-api.service"):
|
||||||
|
expected = (ASSETS / "systemd" / unit).read_text(encoding="utf-8")
|
||||||
|
matching = [c for c in cmds if f"/etc/systemd/system/{unit}" in c]
|
||||||
|
assert matching, f"no command writes {unit}"
|
||||||
|
assert _extract_written_content(matching[0], f"/etc/systemd/system/{unit}") == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_asset_matches_pyproject():
|
||||||
|
# Slice 1a ships a VERSION file alongside the assets; it should match the
|
||||||
|
# single source of truth in pyproject.toml.
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
with open(REPO_ROOT / "pyproject.toml", "rb") as f:
|
||||||
|
version = tomllib.load(f)["project"]["version"]
|
||||||
|
assert (ASSETS / "VERSION").read_text().strip() == version
|
||||||
|
|
@ -144,799 +144,56 @@ def build_disk_config(boot_drive):
|
||||||
# page + live status tiles on :80, avahi advertises proksi.local, and the
|
# page + live status tiles on :80, avahi advertises proksi.local, and the
|
||||||
# console shows a welcome banner pointing at the URL.
|
# console shows a welcome banner pointing at the URL.
|
||||||
#
|
#
|
||||||
# Files are shipped inline (base64-encoded) rather than copied from the live
|
# Asset files (HTML, CSS, shell scripts, systemd units, Caddyfile) live in
|
||||||
# ISO because archinstall's chroot can't see the live filesystem. Payload is
|
# furtka/assets/ in the repo — at ISO build time they end up on the live ISO
|
||||||
# small (~200 lines across 9 files) so this is cheaper than a tarball dance.
|
# as part of the webinstaller's source tree AND inside the resource-manager
|
||||||
|
# payload tarball. The installer reads them from the live-ISO copy, base64-
|
||||||
|
# encodes them, and hands them to archinstall so the chroot recreates each
|
||||||
|
# file bit-for-bit. Updates (Phase 2) refresh the tarball, which carries the
|
||||||
|
# same assets to the target's /opt/furtka/ tree.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_CADDYFILE = """\
|
|
||||||
# Serves the Furtka landing page + status.json on :80. Static for the
|
|
||||||
# landing page; /apps and /api are reverse-proxied to the local resource-
|
|
||||||
# manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth come
|
|
||||||
# later when Authentik is wired in.
|
|
||||||
:80 {
|
|
||||||
\thandle /api/* {
|
|
||||||
\t\treverse_proxy localhost:7000
|
|
||||||
\t}
|
|
||||||
\thandle /apps* {
|
|
||||||
\t\treverse_proxy localhost:7000
|
|
||||||
\t}
|
|
||||||
\thandle {
|
|
||||||
\t\troot * /srv/furtka/www
|
|
||||||
\t\tfile_server
|
|
||||||
\t\tencode gzip
|
|
||||||
\t}
|
|
||||||
\tlog {
|
|
||||||
\t\toutput stdout
|
|
||||||
\t}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
_INDEX_HTML = """\
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>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="/" aria-current="page">Home</a>
|
|
||||||
<a href="/apps">Apps</a>
|
|
||||||
<a href="/settings/">Settings</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<header>
|
|
||||||
<h1>Welcome to Furtka</h1>
|
|
||||||
<p class="lead">Your home server is ready.</p>
|
|
||||||
<p class="host">Running on <code>__HOSTNAME__</code></p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Your apps</h2>
|
|
||||||
<div id="apps-section" class="grid-apps">
|
|
||||||
<a class="app-tile" href="/apps"><span class="name">Loading…</span></a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>System status</h2>
|
|
||||||
<div class="tiles">
|
|
||||||
<div class="tile">
|
|
||||||
<span class="label">Uptime</span>
|
|
||||||
<span class="value" id="uptime">—</span>
|
|
||||||
</div>
|
|
||||||
<div class="tile">
|
|
||||||
<span class="label">Docker</span>
|
|
||||||
<span class="value" id="docker">—</span>
|
|
||||||
</div>
|
|
||||||
<div class="tile">
|
|
||||||
<span class="label">Free disk</span>
|
|
||||||
<span class="value" id="disk">—</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="updated">Updated <span id="updated">—</span></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Coming next</h2>
|
|
||||||
<div class="coming">
|
|
||||||
<p class="hint">Features we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
|
|
||||||
<a href="https://furtka.org/#planned">Photos</a>
|
|
||||||
<a href="https://furtka.org/#planned">Smart home</a>
|
|
||||||
<a href="https://furtka.org/#planned">Media streaming</a>
|
|
||||||
<a href="https://furtka.org/#planned">Multiple boxes</a>
|
|
||||||
<a href="https://furtka.org/#planned">Secure link</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>
|
|
||||||
const HOSTNAME = "__HOSTNAME__";
|
|
||||||
const FALLBACK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = s == null ? '' : String(s);
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function primaryAction(app) {
|
|
||||||
// Only fileshare has a direct "open" link today. Future apps with
|
|
||||||
// HTTP endpoints would surface a URL here; everything else falls
|
|
||||||
// back to the /apps manage page.
|
|
||||||
if (app.name === 'fileshare') {
|
|
||||||
return { href: `smb://${HOSTNAME}.local/files`, label: 'Open files' };
|
|
||||||
}
|
|
||||||
return { href: '/apps', label: 'Manage →' };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderApps() {
|
|
||||||
const target = document.getElementById('apps-section');
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/apps', { cache: 'no-store' });
|
|
||||||
if (!r.ok) throw new Error('api');
|
|
||||||
const apps = (await r.json()).filter(a => a.ok !== false);
|
|
||||||
if (!apps.length) {
|
|
||||||
target.innerHTML = '<a class="app-tile" href="/apps">' +
|
|
||||||
'<span class="name">No apps yet</span>' +
|
|
||||||
'<span class="cta">Install your first app →</span></a>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
target.innerHTML = apps.map(a => {
|
|
||||||
const icon = a.icon_svg || FALLBACK_ICON;
|
|
||||||
const { href, label } = primaryAction(a);
|
|
||||||
return `<a class="app-tile" href="${esc(href)}">
|
|
||||||
<div class="icon">${icon}</div>
|
|
||||||
<span class="name">${esc(a.display_name || a.name)}</span>
|
|
||||||
<span class="cta">${esc(label)}</span>
|
|
||||||
</a>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) {
|
|
||||||
target.innerHTML = '<a class="app-tile" href="/apps">' +
|
|
||||||
'<span class="name">Manage apps</span>' +
|
|
||||||
'<span class="cta">Open →</span></a>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/status.json', { cache: 'no-store' });
|
|
||||||
if (!r.ok) return;
|
|
||||||
const s = await r.json();
|
|
||||||
document.getElementById('uptime').textContent = s.uptime || '—';
|
|
||||||
document.getElementById('docker').textContent = s.docker_version || '—';
|
|
||||||
document.getElementById('disk').textContent = s.disk_free || '—';
|
|
||||||
document.getElementById('updated').textContent = s.updated_at || '—';
|
|
||||||
} catch (e) {
|
|
||||||
/* next tick will retry */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderApps();
|
|
||||||
refresh();
|
|
||||||
setInterval(refresh, 15000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
_STYLE_CSS = """\
|
|
||||||
/* Furtka on-box design system. Served by Caddy at /style.css,
|
|
||||||
consumed by the landing page AND the resource-manager /apps
|
|
||||||
page. One source of truth for tokens + components. */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0f1115;
|
|
||||||
--fg: #e8eaed;
|
|
||||||
--muted: #9aa0a6;
|
|
||||||
--accent: #6ee7b7;
|
|
||||||
--accent-soft: rgba(110, 231, 183, 0.12);
|
|
||||||
--card: #1a1d24;
|
|
||||||
--card-hover: #222530;
|
|
||||||
--border: #2a2d34;
|
|
||||||
--warn: #4a3030;
|
|
||||||
--warn-fg: #fed;
|
|
||||||
--danger: #f08080;
|
|
||||||
|
|
||||||
--r-sm: 4px;
|
|
||||||
--r-md: 8px;
|
|
||||||
--r-lg: 12px;
|
|
||||||
--r-pill: 999px;
|
|
||||||
|
|
||||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
||||||
--ring: 0 0 0 2px var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
--bg: #f7f6f3;
|
|
||||||
--fg: #17181c;
|
|
||||||
--muted: #5e6066;
|
|
||||||
--accent: #0f8a5f;
|
|
||||||
--accent-soft: rgba(15, 138, 95, 0.12);
|
|
||||||
--card: #ffffff;
|
|
||||||
--card-hover: #f0efeb;
|
|
||||||
--border: #e3e1dc;
|
|
||||||
--warn: #fde2d3;
|
|
||||||
--warn-fg: #5a2a10;
|
|
||||||
--danger: #c03a28;
|
|
||||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shared page container — both landing and /apps wrap content in
|
|
||||||
<main class="wrap"> so sizing + padding stay consistent. */
|
|
||||||
.wrap { max-width: 780px; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; }
|
|
||||||
|
|
||||||
/* Top nav — persistent across pages (Jakob's Law). */
|
|
||||||
.nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-bottom: 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--fg);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
.brand::before {
|
|
||||||
content: "";
|
|
||||||
width: 0.7rem;
|
|
||||||
height: 0.7rem;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 2px;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
.nav-links { display: flex; gap: 0.25rem; }
|
|
||||||
.nav-links a {
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
}
|
|
||||||
.nav-links a:hover { color: var(--fg); }
|
|
||||||
.nav-links a[aria-current="page"] {
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- Landing page ---------------------------------------------- */
|
|
||||||
header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; }
|
|
||||||
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
|
|
||||||
.host { color: var(--muted); margin: 0 0 3rem; }
|
|
||||||
.host code {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
section h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 2rem 0 1rem;
|
|
||||||
}
|
|
||||||
.tiles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.tile {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.tile .label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
.tile .value { font-size: 1.25rem; margin-top: 0.5rem; }
|
|
||||||
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
|
|
||||||
.soon {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
margin-top: 4rem;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
footer a { color: var(--accent); }
|
|
||||||
|
|
||||||
/* -- Apps page ------------------------------------------------- */
|
|
||||||
h1 { font-size: 2rem; margin: 0; }
|
|
||||||
h2 {
|
|
||||||
font-size: 1rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 2rem 0 0.75rem;
|
|
||||||
}
|
|
||||||
.lede { color: var(--muted); margin: 0.25rem 0 1rem; }
|
|
||||||
.warn {
|
|
||||||
background: var(--warn);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
color: var(--warn-fg);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.app {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
.app .left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.meta { display: flex; flex-direction: column; min-width: 0; }
|
|
||||||
.name { font-weight: 600; font-size: 1.05rem; }
|
|
||||||
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
|
|
||||||
.desc {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
color: var(--bg);
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
button.secondary {
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
button.danger { background: var(--danger); color: #fff; }
|
|
||||||
button:disabled { opacity: 0.5; cursor: wait; }
|
|
||||||
button:focus-visible { outline: none; box-shadow: var(--ring); }
|
|
||||||
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
|
|
||||||
pre {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
details.log-details {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
details.log-details > summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
details.log-details[open] > summary { color: var(--fg); }
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: none;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.modal-backdrop.open { display: flex; }
|
|
||||||
.modal {
|
|
||||||
background: var(--card);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
padding: 1.5rem;
|
|
||||||
max-width: 520px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; }
|
|
||||||
.modal .long {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.field { margin-bottom: 1rem; }
|
|
||||||
.field label {
|
|
||||||
display: block;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; }
|
|
||||||
.field input {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
||||||
.field .req { color: var(--danger); margin-left: 0.25rem; }
|
|
||||||
.modal .error {
|
|
||||||
background: var(--warn);
|
|
||||||
color: var(--warn-fg);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.modal .error.show { display: block; }
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- Shared primitives for later slices ------------------------ */
|
|
||||||
.chip {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 0.15rem 0.6rem;
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
}
|
|
||||||
.chip-muted { color: var(--muted); }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
.card + .card { margin-top: 1rem; }
|
|
||||||
.card h3 { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
|
||||||
|
|
||||||
.kv {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: max-content 1fr;
|
|
||||||
column-gap: 1.25rem;
|
|
||||||
row-gap: 0.4rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.kv dt { color: var(--muted); }
|
|
||||||
.kv dd { margin: 0; color: var(--fg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
||||||
|
|
||||||
.coming {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
.coming a {
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.coming a:hover { color: var(--fg); border-color: var(--accent); }
|
|
||||||
.coming .hint {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-apps {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.app-tile {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--fg);
|
|
||||||
transition: border-color 120ms, background 120ms;
|
|
||||||
}
|
|
||||||
.app-tile:hover { border-color: var(--accent); background: var(--card-hover); }
|
|
||||||
.app-tile .icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
color: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.app-tile .icon svg { width: 100%; height: 100%; }
|
|
||||||
.app-tile .name { font-weight: 600; font-size: 0.95rem; }
|
|
||||||
.app-tile .cta { color: var(--accent); font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* Icon slot inside a /apps row. The app icon inherits currentColor
|
|
||||||
so a folder path rendered with fill="currentColor" picks up the
|
|
||||||
accent, while a nested <path> using stroke="var(--accent)" still
|
|
||||||
gets the brand color. */
|
|
||||||
.app-icon {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--accent-soft);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.app-icon svg { width: 36px; height: 36px; }
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SETTINGS_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>
|
|
||||||
</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>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 || '—';
|
|
||||||
} catch (e) {
|
|
||||||
/* next tick will retry */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refresh();
|
|
||||||
setInterval(refresh, 15000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
_STATUS_JSON_PLACEHOLDER = """\
|
|
||||||
{
|
|
||||||
"hostname": "",
|
|
||||||
"uptime": "starting…",
|
|
||||||
"docker_version": "starting…",
|
|
||||||
"disk_free": "starting…",
|
|
||||||
"ip_primary": "",
|
|
||||||
"kernel": "",
|
|
||||||
"ram_total": "",
|
|
||||||
"furtka_version": "",
|
|
||||||
"updated_at": ""
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FURTKA_STATUS_SH = """\
|
|
||||||
#!/bin/bash
|
|
||||||
# Writes /srv/furtka/www/status.json with current system stats. Fired by
|
|
||||||
# furtka-status.timer every 30s; also runs once 10s after boot.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
out=/srv/furtka/www/status.json
|
|
||||||
tmp=$(mktemp)
|
|
||||||
|
|
||||||
hostname=$(cat /etc/hostname)
|
|
||||||
uptime=$(uptime -p 2>/dev/null | sed 's/^up //' || echo unknown)
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
|
||||||
docker_version=$(docker --version 2>/dev/null \
|
|
||||||
| awk '{print $3}' | tr -d ',' || echo unavailable)
|
|
||||||
else
|
|
||||||
docker_version=unavailable
|
|
||||||
fi
|
|
||||||
disk_free=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free of " $2}' || echo unknown)
|
|
||||||
ip_primary=$(ip -4 -o addr show scope global 2>/dev/null \
|
|
||||||
| awk '{print $4}' | cut -d/ -f1 | head -1 || true)
|
|
||||||
kernel=$(uname -r 2>/dev/null || echo unknown)
|
|
||||||
ram_total=$(free -h --si 2>/dev/null | awk '/^Mem:/ {print $2}' || echo unknown)
|
|
||||||
# TODO(furtka-version): wire into the installer so /etc/furtka/version
|
|
||||||
# gets pinned at install time. Until then the settings page shows "dev".
|
|
||||||
furtka_version=$(cat /etc/furtka/version 2>/dev/null || echo dev)
|
|
||||||
updated_at=$(date -Iseconds)
|
|
||||||
|
|
||||||
cat > "$tmp" <<EOF
|
|
||||||
{
|
|
||||||
"hostname": "$hostname",
|
|
||||||
"uptime": "$uptime",
|
|
||||||
"docker_version": "$docker_version",
|
|
||||||
"disk_free": "$disk_free",
|
|
||||||
"ip_primary": "$ip_primary",
|
|
||||||
"kernel": "$kernel",
|
|
||||||
"ram_total": "$ram_total",
|
|
||||||
"furtka_version": "$furtka_version",
|
|
||||||
"updated_at": "$updated_at"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
mv "$tmp" "$out"
|
|
||||||
chmod 644 "$out"
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FURTKA_STATUS_SERVICE = """\
|
|
||||||
[Unit]
|
|
||||||
Description=Refresh Furtka system status JSON
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/local/bin/furtka-status
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FURTKA_STATUS_TIMER = """\
|
|
||||||
[Unit]
|
|
||||||
Description=Refresh Furtka system status every 30s
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=10s
|
|
||||||
OnUnitActiveSec=30s
|
|
||||||
AccuracySec=5s
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FURTKA_WELCOME_SH = """\
|
|
||||||
#!/bin/bash
|
|
||||||
# Regenerates /etc/issue on the installed system so the console tells the
|
|
||||||
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
hostname=$(cat /etc/hostname)
|
|
||||||
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
|
||||||
|
|
||||||
{
|
|
||||||
echo
|
|
||||||
echo " Furtka is ready."
|
|
||||||
echo
|
|
||||||
echo " Open in a browser on another device on your network:"
|
|
||||||
echo
|
|
||||||
echo " http://${hostname}.local (easy — try this first)"
|
|
||||||
if [ -n "$ip" ]; then
|
|
||||||
echo " http://${ip} (fallback if the first doesn't work)"
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
} > /etc/issue
|
|
||||||
|
|
||||||
agetty --reload 2>/dev/null || true
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FURTKA_WELCOME_SERVICE = """\
|
|
||||||
[Unit]
|
|
||||||
Description=Furtka console welcome banner
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/local/bin/furtka-welcome
|
|
||||||
RemainAfterExit=yes
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Tarball built by iso/build.sh containing the furtka/ Python package + the
|
# Tarball built by iso/build.sh containing the furtka/ Python package + the
|
||||||
# bundled apps/ tree. The webinstaller reads it from the live ISO at
|
# bundled apps/ tree (plus furtka/assets/). The webinstaller reads it from the
|
||||||
# request-time and base64-encodes it into a custom_command for archinstall.
|
# live ISO at request-time and base64-encodes it into a custom_command for
|
||||||
|
# archinstall.
|
||||||
RESOURCE_MANAGER_PAYLOAD = Path("/opt/furtka-resource-manager.tar.gz")
|
RESOURCE_MANAGER_PAYLOAD = Path("/opt/furtka-resource-manager.tar.gz")
|
||||||
|
|
||||||
|
|
||||||
|
# Asset root. Two layouts we have to handle:
|
||||||
|
# dev / tests — webinstaller/app.py sits at repo_root/webinstaller/ and
|
||||||
|
# assets live at repo_root/furtka/assets/.
|
||||||
|
# live ISO — iso/build.sh copies webinstaller/ to /opt/furtka/ AND
|
||||||
|
# copies furtka/assets/ to /opt/furtka/assets/ right next to
|
||||||
|
# app.py, so the same "assets next to me" lookup works.
|
||||||
|
# Probe the sibling path first (ISO case), fall back to the repo layout.
|
||||||
|
def _resolve_assets_dir() -> Path:
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
sibling = here / "assets"
|
||||||
|
if sibling.is_dir():
|
||||||
|
return sibling
|
||||||
|
repo_copy = here.parent / "furtka" / "assets"
|
||||||
|
if repo_copy.is_dir():
|
||||||
|
return repo_copy
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"furtka assets not found near {here} — looked in {sibling} and {repo_copy}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ASSETS_DIR = _resolve_assets_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_asset(relpath: str) -> str:
|
||||||
|
"""Return the UTF-8 contents of an on-disk asset shipped under furtka/assets/.
|
||||||
|
|
||||||
|
Raises FileNotFoundError if the asset is missing, which is loud by design:
|
||||||
|
an install that tries to write an asset that isn't there is broken before
|
||||||
|
the user ever boots the target, not after.
|
||||||
|
"""
|
||||||
|
path = _ASSETS_DIR / relpath
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
_FURTKA_WRAPPER_SH = """\
|
_FURTKA_WRAPPER_SH = """\
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Tiny launcher for the furtka resource-manager CLI. The Python source lives
|
# Tiny launcher for the furtka resource-manager CLI. The Python source lives
|
||||||
|
|
@ -945,39 +202,6 @@ _FURTKA_WRAPPER_SH = """\
|
||||||
PYTHONPATH=/opt/furtka exec python3 -m furtka.cli "$@"
|
PYTHONPATH=/opt/furtka exec python3 -m furtka.cli "$@"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_FURTKA_RECONCILE_SERVICE = """\
|
|
||||||
[Unit]
|
|
||||||
Description=Furtka app reconciler (boot-scan)
|
|
||||||
Requires=docker.service
|
|
||||||
After=docker.service network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/local/bin/furtka reconcile
|
|
||||||
RemainAfterExit=no
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FURTKA_API_SERVICE = """\
|
|
||||||
[Unit]
|
|
||||||
Description=Furtka resource-manager HTTP API + UI
|
|
||||||
Requires=docker.service
|
|
||||||
After=docker.service network-online.target furtka-reconcile.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/bin/furtka serve --host 127.0.0.1 --port 7000
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _write_file_cmd(path, content, mode=None):
|
def _write_file_cmd(path, content, mode=None):
|
||||||
"""Shell command that recreates `path` with `content` inside the chroot.
|
"""Shell command that recreates `path` with `content` inside the chroot.
|
||||||
|
|
@ -1015,8 +239,14 @@ def _resource_manager_commands():
|
||||||
return [
|
return [
|
||||||
untar_cmd,
|
untar_cmd,
|
||||||
_write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"),
|
_write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"),
|
||||||
_write_file_cmd("/etc/systemd/system/furtka-reconcile.service", _FURTKA_RECONCILE_SERVICE),
|
_write_file_cmd(
|
||||||
_write_file_cmd("/etc/systemd/system/furtka-api.service", _FURTKA_API_SERVICE),
|
"/etc/systemd/system/furtka-reconcile.service",
|
||||||
|
_read_asset("systemd/furtka-reconcile.service"),
|
||||||
|
),
|
||||||
|
_write_file_cmd(
|
||||||
|
"/etc/systemd/system/furtka-api.service",
|
||||||
|
_read_asset("systemd/furtka-api.service"),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1034,16 +264,36 @@ def _post_install_commands(hostname):
|
||||||
# landing page doesn't need a server-side template.
|
# landing page doesn't need a server-side template.
|
||||||
hostname_sed = f"sed -i 's/__HOSTNAME__/{hostname}/g' /srv/furtka/www/index.html"
|
hostname_sed = f"sed -i 's/__HOSTNAME__/{hostname}/g' /srv/furtka/www/index.html"
|
||||||
return [
|
return [
|
||||||
_write_file_cmd("/etc/caddy/Caddyfile", _CADDYFILE),
|
_write_file_cmd("/etc/caddy/Caddyfile", _read_asset("Caddyfile")),
|
||||||
_write_file_cmd("/srv/furtka/www/index.html", _INDEX_HTML),
|
_write_file_cmd("/srv/furtka/www/index.html", _read_asset("www/index.html")),
|
||||||
_write_file_cmd("/srv/furtka/www/settings/index.html", _SETTINGS_HTML),
|
_write_file_cmd(
|
||||||
_write_file_cmd("/srv/furtka/www/style.css", _STYLE_CSS),
|
"/srv/furtka/www/settings/index.html",
|
||||||
_write_file_cmd("/srv/furtka/www/status.json", _STATUS_JSON_PLACEHOLDER),
|
_read_asset("www/settings/index.html"),
|
||||||
_write_file_cmd("/usr/local/bin/furtka-status", _FURTKA_STATUS_SH, mode="755"),
|
),
|
||||||
_write_file_cmd("/usr/local/bin/furtka-welcome", _FURTKA_WELCOME_SH, mode="755"),
|
_write_file_cmd("/srv/furtka/www/style.css", _read_asset("www/style.css")),
|
||||||
_write_file_cmd("/etc/systemd/system/furtka-status.service", _FURTKA_STATUS_SERVICE),
|
_write_file_cmd("/srv/furtka/www/status.json", _read_asset("www/status.json")),
|
||||||
_write_file_cmd("/etc/systemd/system/furtka-status.timer", _FURTKA_STATUS_TIMER),
|
_write_file_cmd(
|
||||||
_write_file_cmd("/etc/systemd/system/furtka-welcome.service", _FURTKA_WELCOME_SERVICE),
|
"/usr/local/bin/furtka-status",
|
||||||
|
_read_asset("bin/furtka-status"),
|
||||||
|
mode="755",
|
||||||
|
),
|
||||||
|
_write_file_cmd(
|
||||||
|
"/usr/local/bin/furtka-welcome",
|
||||||
|
_read_asset("bin/furtka-welcome"),
|
||||||
|
mode="755",
|
||||||
|
),
|
||||||
|
_write_file_cmd(
|
||||||
|
"/etc/systemd/system/furtka-status.service",
|
||||||
|
_read_asset("systemd/furtka-status.service"),
|
||||||
|
),
|
||||||
|
_write_file_cmd(
|
||||||
|
"/etc/systemd/system/furtka-status.timer",
|
||||||
|
_read_asset("systemd/furtka-status.timer"),
|
||||||
|
),
|
||||||
|
_write_file_cmd(
|
||||||
|
"/etc/systemd/system/furtka-welcome.service",
|
||||||
|
_read_asset("systemd/furtka-welcome.service"),
|
||||||
|
),
|
||||||
nss_sed,
|
nss_sed,
|
||||||
hostname_sed,
|
hostname_sed,
|
||||||
*_resource_manager_commands(),
|
*_resource_manager_commands(),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue