Status LEDs, image model selection, backup restore, provider test, monthly versions

- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status)
- Images: preview stage, purpose-based model choice and reference images
  (Nano Banana Pro edit refines the chosen preview)
- Settings: restore a stored or uploaded backup (staged, applied on restart)
- Provider form: test connection; saving or reloading re-checks the status at once
- opencode Zen/Go: send x-opencode-session with a stable id per conversation
- Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes
- Fix: backups made in the same second no longer overwrite each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 09:31:24 +01:00
1 parent 49d404eec7
commit 1f69d63b09
35 files changed
+1156 -88

No files matched your search

+37 -1
View File
@@ -461,6 +461,33 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
return savePatches(patches);
});
// Check URL and key as entered, without saving.
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }>();
const [testing, setTesting] = useState(false);
const test = async () => {
setTesting(true);
setTestResult(undefined);
try {
const r = await post<{ status: ProviderStatus; models: number }>("/providers/test", {
name,
kind: form.kind,
base_url: form.base_url,
api_key: form.api_key,
auth_prefix: form.auth_prefix ?? "",
});
const st = r.status;
setTestResult(
st.up
? { ok: true, text: t("settings.testOk", { ms: st.latency_ms, n: r.models }) }
: { ok: false, text: `${st.state === "auth" ? t("settings.providerAuth") : t("settings.providerDown")}: ${st.error ?? ""}` },
);
} catch (e: any) {
setTestResult({ ok: false, text: e.message });
} finally {
setTesting(false);
}
};
const remove = async () => {
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
void run(() => savePatches([{ path: ["providers", name], value: null }]));
@@ -614,7 +641,15 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
</div>
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
{view}
{testResult && (
<div className={`notice ${testResult.ok ? "ok" : "err"}`} style={{ marginTop: 12 }}>
{testResult.text}
</div>
)}
<div className="row" style={{ marginTop: 12 }}>
<button onClick={test} disabled={testing || !form.base_url}>
<Icon name="plug" /> {testing ? t("settings.testing") : t("settings.testConnection")}
</button>
<button className="primary" onClick={save}>
{t("common.save")}
</button>
@@ -637,9 +672,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
const providers = cfg.config.providers ?? {};
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
const [checking, setChecking] = useState(false);
// Saving a provider re-checks it on the server, so refresh whenever the config changes.
useEffect(() => {
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
}, []);
}, [cfg]);
const recheck = () => {
setChecking(true);
post<Record<string, ProviderStatus>>("/providers/status")
+2 -2
View File
@@ -1,4 +1,5 @@
import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, patch, post } from "../api";
@@ -102,7 +103,6 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
const t = useT();
const sessions = useStore((s) => s.sessions);
const busy = useStore((s) => s.busy);
const connected = useStore((s) => s.connected);
const permissions = useStore((s) => s.permissions);
const questions = useStore((s) => s.questions);
const me = useStore((s) => s.me);
@@ -187,7 +187,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<img src="/icon.svg" alt="" />
CodeCraft
</div>
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
<StatusLeds />
</div>
<div style={{ padding: "0 12px 8px" }}>
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
+200
View File
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { get, post } from "../api";
import { useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { Icon } from "./Icon";
type Level = "ok" | "warn" | "err" | "off";
interface ProviderState {
state: "ok" | "auth" | "down" | "unknown";
latency_ms?: number;
checked_at?: number;
error?: string | null;
models: number;
}
interface McpEntry {
state: "connected" | "connecting" | "failed";
tools?: number;
error?: string | null;
}
interface Overview {
version: string;
providers: Record<string, ProviderState>;
mcp: Record<string, McpEntry>;
}
const POLL_MS = 30_000;
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
const RECONNECT_GRACE_MS = 10_000;
/** Combined level of a group: all fine → ok, some broken → warn, none working → err. */
function combine(levels: Level[]): Level {
const active = levels.filter((l) => l !== "off");
if (active.length === 0) return "off";
if (active.every((l) => l === "ok")) return "ok";
if (active.some((l) => l === "ok" || l === "warn")) return "warn";
return "err";
}
const providerLevel = (p: ProviderState): Level => (p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err");
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
function Led({ level, title }: { level: Level; title?: string }) {
return <span className={`led led-${level}`} title={title} />;
}
/** Three status LEDs (server, models, MCP) in the sidebar; a click opens the details. */
export function StatusLeds() {
const t = useT();
const connected = useStore((s) => s.connected);
const admin = useStore((s) => s.me?.role === "admin");
const [data, setData] = useState<Overview>();
const [open, setOpen] = useState(false);
const [checking, setChecking] = useState(false);
const [lostAt, setLostAt] = useState<number>();
const [pos, setPos] = useState<{ left: number; top: number }>();
const [, tick] = useState(0);
const root = useRef<HTMLDivElement>(null);
const load = useCallback(() => get<Overview>("/status").then(setData).catch(() => {}), []);
useEffect(() => {
void load();
const id = setInterval(load, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Track when the event connection dropped (yellow while reconnecting, red after a while).
useEffect(() => {
if (connected) {
setLostAt(undefined);
void load();
return;
}
setLostAt((v) => v ?? Date.now());
const id = setTimeout(() => tick((n) => n + 1), RECONNECT_GRACE_MS + 100);
return () => clearTimeout(id);
}, [connected, load]);
// Close on outside click and Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => !root.current?.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const server: Level = connected ? "ok" : lostAt && Date.now() - lostAt > RECONNECT_GRACE_MS ? "err" : "warn";
const providers = Object.entries(data?.providers ?? {});
const mcp = Object.entries(data?.mcp ?? {});
const models: Level = data ? (providers.length ? combine(providers.map(([, p]) => providerLevel(p))) : "err") : "off";
const tools: Level = combine(mcp.map(([, m]) => mcpLevel(m)));
const label = (l: Level) => t(`status.level.${l}` as Key);
const summary = `${t("status.server")}: ${label(server)} · ${t("status.models")}: ${label(models)} · ${t("status.mcp")}: ${label(tools)}`;
const recheck = async () => {
setChecking(true);
try {
await post("/providers/status");
await load();
} finally {
setChecking(false);
}
};
const go = (tab: string) => {
setOpen(false);
navigate(`/settings/${tab}`);
};
const providerText = (p: ProviderState) =>
p.state === "ok"
? t("status.providerOk", { n: p.models, ms: p.latency_ms ?? 0 })
: p.state === "auth"
? t("settings.providerAuth")
: p.state === "down"
? t("settings.providerDown")
: t("status.notChecked");
return (
<div className="status-leds" ref={root}>
<button
className="ghost status-leds-btn"
onClick={(e) => {
// Fixed position below the LEDs, kept inside the window.
const r = e.currentTarget.getBoundingClientRect();
setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 328)), top: r.bottom + 6 });
setOpen(!open);
}} title={summary} aria-label={summary} aria-expanded={open}>
<Led level={server} />
<Led level={models} />
<Led level={tools} />
</button>
{open && (
<div className="status-pop" role="dialog" aria-label={t("status.title")} style={pos}>
<div className="status-section">
<div className="status-head">
<Led level={server} /> {t("status.server")}
</div>
<div className="status-row">
<span className="grow">{connected ? t("sidebar.connected") : t("sidebar.disconnected")}</span>
{data && <span className="hint">v{data.version}</span>}
</div>
</div>
<div className="status-section">
<div className="status-head">
<Led level={models} /> {t("status.models")}
<span className="spacer" />
{admin && (
<button className="small ghost" onClick={recheck} disabled={checking} title={t("status.recheck")}>
<Icon name="refresh" size={13} />
</button>
)}
</div>
{providers.length === 0 && <div className="status-row hint">{t("status.noProviders")}</div>}
{providers.map(([name, p]) => (
<div key={name} className="status-row" title={p.error ?? undefined}>
<Led level={providerLevel(p)} />
<span className="grow mono">{name}</span>
<span className="hint">{providerText(p)}</span>
</div>
))}
</div>
<div className="status-section">
<div className="status-head">
<Led level={tools} /> {t("status.mcp")}
</div>
{mcp.length === 0 && <div className="status-row hint">{t("status.noMcp")}</div>}
{mcp.map(([name, m]) => (
<div key={name} className="status-row" title={m.error ?? undefined}>
<Led level={mcpLevel(m)} />
<span className="grow mono">{name}</span>
<span className="hint">{m.state === "connected" ? t("mcp.tools", { n: m.tools ?? 0 }) : t(`mcp.state.${m.state}` as Key)}</span>
</div>
))}
</div>
{admin && (
<div className="status-foot">
<button className="small ghost" onClick={() => go("providers")}>
<Icon name="brain" size={13} /> {t("status.providerSettings")}
</button>
<button className="small ghost" onClick={() => go("mcp")}>
<Icon name="plug" size={13} /> {t("status.mcpSettings")}
</button>
</div>
)}
</div>
)}
</div>
);
}
+51 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { get, patch, post } from "../../api";
import { toast } from "../../dialogs";
import { useEffect, useRef, useState } from "react";
import { api, get, patch, post } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
import { formatSize } from "../FileFigure";
@@ -90,6 +90,32 @@ export function Backups() {
}
};
// Restore: the server stages the backup, restarts and applies it before opening the database.
const fileInput = useRef<HTMLInputElement>(null);
const [restoring, setRestoring] = useState(false);
const restore = async (source: { name: string } | { file: File }) => {
const label = "name" in source ? source.name : source.file.name;
const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true });
if (!ok) return;
setRestoring(true);
try {
if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`);
else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } });
toast(t("backup.restarting"), "info");
// Wait until the server is gone and back, then reload with the restored data.
await new Promise((r) => setTimeout(r, 2500));
for (let i = 0; i < 120; i++) {
if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload();
await new Promise((r) => setTimeout(r, 1000));
}
toast(t("backup.notBack"));
} catch (e: any) {
toast(e.message);
} finally {
setRestoring(false);
}
};
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
form && (
<label className="field">
@@ -111,6 +137,20 @@ export function Backups() {
<a className="button small" href="/api/backup">
<Icon name="download" /> {t("audit.download")}
</a>
<button className="small" onClick={() => fileInput.current?.click()} disabled={restoring}>
<Icon name="upload" /> {restoring ? t("backup.restoring") : t("backup.restoreFile")}
</button>
<input
ref={fileInput}
type="file"
accept=".zip,application/zip"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void restore({ file });
}}
/>
<button className="small primary" onClick={runNow} disabled={busy}>
{busy ? t("backup.running") : t("backup.now")}
</button>
@@ -176,12 +216,17 @@ export function Backups() {
{data && data.files.length > 0 && (
<div className="backup-list">
{data.files.map((f) => (
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
<div key={f.name} className="backup-file">
<Icon name="file" />
<span className="grow">{f.name}</span>
<span className="hint">{formatSize(f.size)}</span>
<Icon name="download" />
</a>
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
<Icon name="undo" />
</button>
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
<Icon name="download" />
</a>
</div>
))}
</div>
)}