Build MVP with Pi installer and atomic updates

This commit is contained in:
2026-07-31 18:17:27 +02:00
parent e575ed2b2f
commit c52def7c37
35 changed files with 7762 additions and 1 deletions
+378
View File
@@ -0,0 +1,378 @@
import { useCallback, useEffect, useState } from "react";
import {
ArrowClockwise,
CheckCircle,
Cpu,
Gauge,
HardDrives,
House,
Gear,
GitBranch,
DownloadSimple,
SignOut,
TerminalWindow,
WarningCircle,
WifiHigh
} from "@phosphor-icons/react";
import {
ApiError,
getAuthState,
getStatus,
getUpdateStatus,
post,
type AuthState,
type SystemStatus,
type UpdateStatus
} from "./api";
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
export function App() {
const [screen, setScreen] = useState<Screen>("loading");
const [auth, setAuth] = useState<AuthState | null>(null);
const [fatalError, setFatalError] = useState("");
const refreshAuth = useCallback(async () => {
try {
const state = await getAuthState();
setAuth(state);
setScreen(state.setupRequired ? "setup" : state.user ? "dashboard" : "login");
} catch (error) {
setFatalError(error instanceof Error ? error.message : "The companion service is unreachable");
setScreen("fatal");
}
}, []);
useEffect(() => void refreshAuth(), [refreshAuth]);
if (screen === "loading") return <BootScreen />;
if (screen === "fatal") return <ConnectionError message={fatalError} retry={() => void refreshAuth()} />;
if (!auth) return null;
if (screen === "setup") return <AccountScreen mode="setup" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
if (screen === "login") return <AccountScreen mode="login" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
return <Dashboard auth={auth} onLoggedOut={refreshAuth} />;
}
function BootScreen() {
return (
<main className="center-screen" aria-live="polite">
<div className="boot-mark"><Gauge size={44} weight="duotone" /></div>
<p className="eyebrow">Pi Car Companion</p>
<h1>Connecting to your Pi</h1>
<div className="loading-line" aria-hidden="true"><span /></div>
</main>
);
}
function ConnectionError({ message, retry }: { message: string; retry: () => void }) {
return (
<main className="center-screen error-screen">
<WarningCircle size={52} weight="duotone" aria-hidden="true" />
<p className="eyebrow">Connection unavailable</p>
<h1>The Pi did not respond</h1>
<p className="screen-copy">{message}. Check power and network access, then reconnect.</p>
<button className="primary-button" onClick={retry}><ArrowClockwise size={22} /> Reconnect</button>
</main>
);
}
function AccountScreen({
mode,
csrfToken,
onComplete
}: {
mode: "setup" | "login";
csrfToken: string;
onComplete: () => Promise<void>;
}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const setup = mode === "setup";
async function submit(event: React.FormEvent) {
event.preventDefault();
setError("");
setSubmitting(true);
try {
if (setup) await post("/api/auth/setup", { username, password }, csrfToken);
await post("/api/auth/login", { username, password }, csrfToken);
await onComplete();
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "The request could not be completed");
} finally {
setSubmitting(false);
}
}
return (
<main className="account-layout">
<section className="account-context">
<div className="brand"><Gauge size={32} weight="duotone" /><span>Pi Car Companion</span></div>
<div>
<p className="eyebrow">{setup ? "First run" : "Local access"}</p>
<h1>{setup ? "Secure your companion." : "Welcome back."}</h1>
<p>{setup ? "Create the only initial administrator. There are no default credentials." : "Sign in to view live Pi status and controlled actions."}</p>
</div>
<p className="local-note"><WifiHigh size={22} /> Credentials stay on this Pi.</p>
</section>
<section className="account-form-wrap">
<form className="account-form" onSubmit={(event) => void submit(event)}>
<h2>{setup ? "Create administrator" : "Sign in"}</h2>
<label>
<span>Username</span>
<input autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={48} required autoFocus />
</label>
<label>
<span>Password</span>
<input type="password" autoComplete={setup ? "new-password" : "current-password"} value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} maxLength={256} required />
{setup && <small>Use at least 12 characters.</small>}
</label>
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
<button className="primary-button" type="submit" disabled={submitting}>
{submitting ? "Working..." : setup ? "Create account" : "Sign in"}
</button>
</form>
</section>
</main>
);
}
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
const [page, setPage] = useState<"home" | "settings">("home");
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState("");
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
const [refreshing, setRefreshing] = useState(false);
const refresh = useCallback(async () => {
setRefreshing(true);
try {
setStatus(await getStatus());
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Status could not be loaded");
} finally {
setRefreshing(false);
}
}, []);
useEffect(() => {
void refresh();
const events = new EventSource("/api/events");
events.addEventListener("open", () => setConnection("live"));
events.addEventListener("status", (event) => {
setStatus(JSON.parse((event as MessageEvent<string>).data) as SystemStatus);
setConnection("live");
setError("");
});
events.addEventListener("error", () => setConnection("offline"));
return () => events.close();
}, [refresh]);
async function logout() {
await post("/api/auth/logout", {}, auth.csrfToken);
await onLoggedOut();
}
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
<nav aria-label="Primary navigation">
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
</nav>
<button className="nav-item logout" onClick={() => void logout()}><SignOut size={24} /><span>Sign out</span></button>
</aside>
<main className="dashboard">
<header className="dashboard-header">
<div>
<p className="eyebrow">{page === "home" ? "System overview" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : "Settings"}</h1>
</div>
<div className="header-actions">
<div className={`connection-state ${connection}`}>
<span aria-hidden="true" /> {connection === "live" ? "Live" : connection === "connecting" ? "Connecting" : "Reconnecting"}
</div>
{page === "home" && <button className="secondary-button" onClick={() => void refresh()} disabled={refreshing}>
<ArrowClockwise size={22} className={refreshing ? "spin" : ""} /> Refresh
</button>}
</div>
</header>
{page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
</> : <SettingsPage csrfToken={auth.csrfToken} />}
</main>
</div>
);
}
function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [update, setUpdate] = useState<UpdateStatus | null>(null);
const [error, setError] = useState("");
const [requesting, setRequesting] = useState(false);
const [confirming, setConfirming] = useState(false);
const running = update?.state === "checking" || update?.state === "building";
const refreshUpdate = useCallback(async () => {
try {
setUpdate(await getUpdateStatus());
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Update status is unavailable");
}
}, []);
useEffect(() => {
void refreshUpdate();
const interval = window.setInterval(() => void refreshUpdate(), 3_000);
return () => window.clearInterval(interval);
}, [refreshUpdate]);
async function startUpdate() {
setConfirming(false);
setRequesting(true);
setError("");
try {
await post("/api/system/update", {}, csrfToken);
await refreshUpdate();
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "The update could not be started");
} finally {
setRequesting(false);
}
}
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready";
return (
<div className="settings-layout">
<section className="settings-panel">
<div className="settings-title">
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
<div><h2>Software update</h2><p>Fetch, verify, build, and activate the newest release from the configured Git branch.</p></div>
</div>
<dl className="update-facts">
<div><dt>Status</dt><dd className={`update-state ${update?.state ?? "idle"}`}>{statusLabel}</dd></div>
<div><dt>Installed revision</dt><dd><GitBranch size={18} /> <code>{revision}</code></dd></div>
<div><dt>Last activity</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
</dl>
<div className="update-message" aria-live="polite">
{running && <ArrowClockwise size={22} className="spin" />}
{!running && update?.state === "failed" && <WarningCircle size={22} />}
{!running && update?.state !== "failed" && <CheckCircle size={22} />}
<span>{update?.message ?? "Reading update status..."}</span>
</div>
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
<div className="settings-actions">
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
<DownloadSimple size={22} /> {running ? "Updating..." : "Update now"}
</button>
<button className="secondary-button" onClick={() => void refreshUpdate()} disabled={requesting}>Refresh status</button>
</div>
<p className="settings-note">The current release stays active unless the new version passes installation, lint, type checks, tests, and production builds.</p>
</section>
<section className="settings-panel compact-panel">
<h2>Service behavior</h2>
<dl className="behavior-list">
<div><dt>Start on boot</dt><dd>{update?.supported ? "Enabled by systemd" : "Available after install"}</dd></div>
<div><dt>Failure recovery</dt><dd>{update?.supported ? "Automatic restart" : "Available after install"}</dd></div>
<div><dt>Update channel</dt><dd>{update?.supported ? "Configured Git branch" : "Not configured"}</dd></div>
</dl>
</section>
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="update-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
<h2 id="update-dialog-title">Install the latest version?</h2>
<p>The dashboard may disconnect briefly when the service restarts. The previous version remains active if verification fails.</p>
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setConfirming(false)}>Cancel</button>
<button className="primary-button" onClick={() => void startUpdate()}>Install update</button>
</div>
</div>
</div>}
</div>
);
}
function StatusContent({ status }: { status: SystemStatus }) {
const memoryPercent = percent(status.memory.usedBytes, status.memory.totalBytes);
const diskPercent = status.disk.available ? percent(status.disk.value.usedBytes, status.disk.value.totalBytes) : null;
const primaryAddress = status.network.interfaces[0]?.address;
const temperature = status.cpu.temperatureCelsius.available
? `${status.cpu.temperatureCelsius.value.toFixed(1)}°C`
: "Unavailable";
return (
<div className="status-layout">
<section className="health-summary">
<div className="summary-heading">
<div className="health-icon"><CheckCircle size={38} weight="fill" /></div>
<div><p>Companion service</p><h2>Running normally</h2></div>
</div>
<dl className="summary-details">
<div><dt>Host uptime</dt><dd>{formatUptime(status.uptimeSeconds)}</dd></div>
<div><dt>Service uptime</dt><dd>{formatUptime(status.service.processUptimeSeconds)}</dd></div>
<div><dt>Last update</dt><dd>{new Date(status.collectedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</dd></div>
</dl>
</section>
<section className="metrics-grid" aria-label="System metrics">
<Metric icon={<Cpu size={28} />} label="CPU temperature" value={temperature} note={`Load ${status.cpu.loadAverage[0]?.toFixed(2) ?? "Unavailable"}`} />
<Metric icon={<Gauge size={28} />} label="Memory used" value={`${memoryPercent}%`} note={`${formatBytes(status.memory.availableBytes)} available`} meter={memoryPercent} />
<Metric icon={<HardDrives size={28} />} label="Disk used" value={diskPercent === null ? "Unavailable" : `${diskPercent}%`} note={status.disk.available ? `${formatBytes(status.disk.value.availableBytes)} available` : status.disk.reason} meter={diskPercent ?? undefined} />
<Metric icon={<WifiHigh size={28} />} label="Local network" value={primaryAddress ?? "Unavailable"} note={status.network.interfaces[0]?.name ?? status.network.interfaceReason ?? "No active interface"} />
</section>
<section className="system-strip">
<div><span>Operating system</span><strong>{status.operatingSystem}</strong></div>
<div><span>Processor</span><strong>{status.cpu.model}</strong></div>
<div><span>Wi-Fi detail</span><strong>{status.network.wifi.available ? status.network.wifi.value : status.network.wifi.reason}</strong></div>
</section>
</div>
);
}
function Metric({ icon, label, value, note, meter }: { icon: React.ReactNode; label: string; value: string; note: string; meter?: number | undefined }) {
return (
<article className="metric">
<div className="metric-label">{icon}<span>{label}</span></div>
<strong className="metric-value">{value}</strong>
<p>{note}</p>
{meter !== undefined && <div className="meter" aria-label={`${label}: ${meter}%`}><span style={{ width: `${meter}%` }} /></div>}
</article>
);
}
function DashboardSkeleton() {
return <div className="skeleton-grid" aria-label="Loading system status"><div /><div /><div /><div /><div /></div>;
}
function percent(used: number, total: number): number {
return total > 0 ? Math.round((used / total) * 100) : 0;
}
function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
return `${value.toFixed(unit < 2 ? 0 : 1)} ${units[unit]}`;
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}