All checks were successful
CI / lint (pull_request) Successful in 30s
CI / validate-json (pull_request) Successful in 24s
CI / markdown-links (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m47s
CI / lint (push) Successful in 28s
CI / test (push) Successful in 1m56s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 21s
Deploy site / deploy (push) Successful in 5s
Build ISO / build-iso (push) Successful in 20m27s
The logo vote on furtka.org closed with M 29 · R 13 (42 votes). - website/tools/brand-assets.py generates every brand file from the logo-assets.py geometry: favicons (small-size tier, accent with a dark-mode variant) for website / webinstaller / on-box UI, 180 px apple-touch-icon, 1200x630 default share card, 640x480 syslinux splash, and docs/brand/ (mark, small mark, lockups, 20-column console art) - website: header mark, favicon, touch icon, default og:image; /logo/ now shows the final result (static counts, winner highlighted, no voting UI or poll.js); homepage link reads 'meet our new logo' - webinstaller: M in the header + tab icon - on-box UI: .brand mark via CSS mask in brand red (not the UI's green), favicon on every page, brand line on the login page - console: M art above the text of /etc/issue on the installed box (furtka-welcome) and the live ISO (furtka-update-issue) - ISO: build.sh swaps releng's Arch splash for the Furtka one - README shows the lockup Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
181 lines
7.8 KiB
HTML
181 lines
7.8 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
<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>
|
|
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
|
</div>
|
|
</nav>
|
|
<header>
|
|
<h1>Welcome to Furtka</h1>
|
|
<p class="lead">Your home server is ready.</p>
|
|
<p class="host">Running on <code id="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>
|
|
// Sessions live in API memory, so every restart or self-update logs
|
|
// everyone out. Send the browser to the login page instead of rendering
|
|
// the 401 body as if the box had no apps.
|
|
const _fetch = window.fetch.bind(window);
|
|
window.fetch = async (...args) => {
|
|
const r = await _fetch(...args);
|
|
if (r.status === 401) { location.href = '/login'; }
|
|
return r;
|
|
};
|
|
// Revoke the cookie server-side and bounce to /login. Shared
|
|
// shape with the _HTML in furtka/api.py so the two logout
|
|
// links behave identically.
|
|
async function doLogout(ev) {
|
|
ev.preventDefault();
|
|
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
|
|
catch (e) { /* server may already be down */ }
|
|
window.location.href = '/login';
|
|
return false;
|
|
}
|
|
|
|
// Hostname + install metadata — written once at install time to
|
|
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
|
|
// Separate from status.json because these facts don't change between
|
|
// refresh ticks.
|
|
let 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;
|
|
}
|
|
|
|
async function loadFurtkaJson() {
|
|
try {
|
|
const r = await fetch('/furtka.json', { cache: 'no-store' });
|
|
if (!r.ok) return;
|
|
const f = await r.json();
|
|
HOSTNAME = f.hostname || "";
|
|
const el = document.getElementById('hostname');
|
|
if (el) el.textContent = HOSTNAME || '—';
|
|
} catch (e) { /* no-op */ }
|
|
}
|
|
|
|
function primaryAction(app) {
|
|
// open_url is a manifest-declared template with a `{host}`
|
|
// placeholder — substituted against the current browser's
|
|
// hostname so smb://host/files and http://host:3001/ both
|
|
// follow however the user reached Furtka (furtka.local, raw
|
|
// IP, a future reverse-proxy hostname). Apps without a
|
|
// frontend fall back to /apps for management.
|
|
if (app.open_url) {
|
|
const host = HOSTNAME || location.hostname;
|
|
return { href: app.open_url.replace('{host}', host), label: 'Open', external: true };
|
|
}
|
|
return { href: '/apps', label: 'Manage →', external: false };
|
|
}
|
|
|
|
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, external } = primaryAction(a);
|
|
const tgt = external ? ' target="_blank" rel="noopener"' : '';
|
|
return `<a class="app-tile" href="${esc(href)}"${tgt}>
|
|
<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 */
|
|
}
|
|
}
|
|
|
|
// furtka.json must land first so renderApps can build the SMB link
|
|
// with the real hostname. If it 404s (very early in boot) the
|
|
// primary-action falls back to "Manage →".
|
|
loadFurtkaJson().then(renderApps);
|
|
refresh();
|
|
setInterval(refresh, 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|