feat(ui): landing page redesign — apps grid + roadmap placeholders

Slice 4 of the on-box UI uplevel. The landing page is now the peak-end
first impression after install: welcome + hostname chip, a "Your apps"
tile grid consuming /api/apps (with the real icon and an app-specific
primary action — fileshare gets smb://<host>.local/files, everything
else falls back to Manage →), the existing system-status tiles kept
intact, and a subtle "Coming next" row of text-only links that jump to
the planned-features section on furtka.org. No dead tiles.

The status script now also writes ip_primary, kernel, ram_total and a
furtka_version read from /etc/furtka/version (TODO: pin that file at
install time; for now it reports "dev"). The settings page will
consume those in slice 5.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-16 12:27:56 +02:00
parent 358444839c
commit c7ca6bfbb1

View file

@ -1,3 +1,7 @@
# ruff: noqa: E501 — inline HTML/CSS/JS payloads (_INDEX_HTML, _STYLE_CSS,
# _CADDYFILE, _FURTKA_STATUS_SH, etc.) round-trip verbatim to the installed
# system; wrapping them hurts readability and the rendered output is what
# matters.
import base64 import base64
import json import json
import os import os
@ -192,7 +196,14 @@ _INDEX_HTML = """\
<p class="host">Running on <code>__HOSTNAME__</code></p> <p class="host">Running on <code>__HOSTNAME__</code></p>
</header> </header>
<section class="status"> <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> <h2>System status</h2>
<div class="tiles"> <div class="tiles">
<div class="tile"> <div class="tile">
@ -211,9 +222,17 @@ _INDEX_HTML = """\
<p class="updated">Updated <span id="updated"></span></p> <p class="updated">Updated <span id="updated"></span></p>
</section> </section>
<section class="soon"> <section>
<h2>Apps</h2> <h2>Coming next</h2>
<p><a href="/apps">Manage installed apps </a></p> <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> </section>
<footer> <footer>
@ -222,9 +241,56 @@ _INDEX_HTML = """\
</main> </main>
<script> <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() { async function refresh() {
try { try {
const r = await fetch('/status.json', {cache: 'no-store'}); const r = await fetch('/status.json', { cache: 'no-store' });
if (!r.ok) return; if (!r.ok) return;
const s = await r.json(); const s = await r.json();
document.getElementById('uptime').textContent = s.uptime || ''; document.getElementById('uptime').textContent = s.uptime || '';
@ -235,6 +301,8 @@ _INDEX_HTML = """\
/* next tick will retry */ /* next tick will retry */
} }
} }
renderApps();
refresh(); refresh();
setInterval(refresh, 15000); setInterval(refresh, 15000);
</script> </script>
@ -657,6 +725,10 @@ _STATUS_JSON_PLACEHOLDER = """\
"uptime": "starting…", "uptime": "starting…",
"docker_version": "starting…", "docker_version": "starting…",
"disk_free": "starting…", "disk_free": "starting…",
"ip_primary": "",
"kernel": "",
"ram_total": "",
"furtka_version": "",
"updated_at": "" "updated_at": ""
} }
""" """
@ -679,6 +751,13 @@ else
docker_version=unavailable docker_version=unavailable
fi fi
disk_free=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free of " $2}' || echo unknown) 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) updated_at=$(date -Iseconds)
cat > "$tmp" <<EOF cat > "$tmp" <<EOF
@ -687,6 +766,10 @@ cat > "$tmp" <<EOF
"uptime": "$uptime", "uptime": "$uptime",
"docker_version": "$docker_version", "docker_version": "$docker_version",
"disk_free": "$disk_free", "disk_free": "$disk_free",
"ip_primary": "$ip_primary",
"kernel": "$kernel",
"ram_total": "$ram_total",
"furtka_version": "$furtka_version",
"updated_at": "$updated_at" "updated_at": "$updated_at"
} }
EOF EOF