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>
)}
+25
View File
@@ -204,6 +204,21 @@ export const de: Messages = {
"sidebar.yesterday": "Gestern",
"sidebar.week": "Letzte 7 Tage",
"sidebar.older": "Älter",
"status.title": "Status",
"status.server": "Server",
"status.models": "Modelle",
"status.mcp": "MCP",
"status.level.ok": "alles in Ordnung",
"status.level.warn": "teilweise nicht verfügbar",
"status.level.err": "nicht verfügbar",
"status.level.off": "keine konfiguriert",
"status.providerOk": "{n} Modelle · {ms} ms",
"status.notChecked": "noch nicht geprüft",
"status.recheck": "Erneut prüfen",
"status.noProviders": "Keine Provider aktiv.",
"status.noMcp": "Keine MCP-Server konfiguriert.",
"status.providerSettings": "Provider",
"status.mcpSettings": "MCP-Server",
"sidebar.connected": "Verbunden",
"sidebar.disconnected": "Getrennt – verbinde neu…",
"sidebar.needsInput": "Wartet auf deine Eingabe",
@@ -464,6 +479,13 @@ export const de: Messages = {
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
"sidebar.folderName": "Ordnername",
"sidebar.move": "Verschieben",
"backup.restore": "Wiederherstellen",
"backup.restoreFile": "Aus Datei wiederherstellen…",
"backup.restoring": "Wird wiederhergestellt…",
"backup.restoreTitle": "Backup wiederherstellen?",
"backup.restoreConfirm": "Datenbank und Konfiguration durch {file} ersetzen? Alle Sitzungen, Benutzer, Statistiken und Einstellungen werden durch den Stand im Backup ersetzt; Dateien im Workspace bleiben unverändert. Der aktuelle Stand wird vorher gesichert. CodeCraft startet neu – laufende Agents werden beendet.",
"backup.restarting": "Backup übernommen – CodeCraft startet neu…",
"backup.notBack": "Der Server ist nicht zurückgekommen. Er braucht eine Restart-Policy (Docker: restart: unless-stopped) – bitte von Hand starten.",
"backup.now": "Jetzt sichern",
"backup.running": "Sichere…",
"backup.created": "Backup {file} erstellt.",
@@ -647,6 +669,9 @@ export const de: Messages = {
"settings.providerName": "Name (optional)",
"settings.providerUp": "erreichbar · {ms} ms",
"settings.providerDown": "nicht erreichbar",
"settings.testConnection": "Verbindung testen",
"settings.testing": "Teste…",
"settings.testOk": "Verbindung klappt: {n} Modelle, {ms} ms. Noch nicht gespeichert.",
"settings.providerAuth": "API-Key fehlt oder ungültig",
"settings.providerCheck": "Provider prüfen",
"settings.providerChecking": "Prüfe…",
+25
View File
@@ -203,6 +203,21 @@ export const en = {
"sidebar.yesterday": "Yesterday",
"sidebar.week": "Last 7 days",
"sidebar.older": "Older",
"status.title": "Status",
"status.server": "Server",
"status.models": "Models",
"status.mcp": "MCP",
"status.level.ok": "all fine",
"status.level.warn": "partly unavailable",
"status.level.err": "unavailable",
"status.level.off": "none configured",
"status.providerOk": "{n} models · {ms} ms",
"status.notChecked": "not checked yet",
"status.recheck": "Check again",
"status.noProviders": "No providers enabled.",
"status.noMcp": "No MCP servers configured.",
"status.providerSettings": "Providers",
"status.mcpSettings": "MCP servers",
"sidebar.connected": "Connected",
"sidebar.disconnected": "Disconnected – reconnecting…",
"sidebar.needsInput": "Waiting for your input",
@@ -463,6 +478,13 @@ export const en = {
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
"sidebar.folderName": "Folder name",
"sidebar.move": "Move",
"backup.restore": "Restore",
"backup.restoreFile": "Restore from file…",
"backup.restoring": "Restoring…",
"backup.restoreTitle": "Restore backup?",
"backup.restoreConfirm": "Replace the database and the configuration with {file}? All sessions, users, statistics and settings are replaced by the state in the backup; files in the workspace stay as they are. The current state is backed up first. CodeCraft restarts – running agents are stopped.",
"backup.restarting": "Backup accepted – CodeCraft is restarting…",
"backup.notBack": "The server did not come back. It needs a restart policy (Docker: restart: unless-stopped) – start it again by hand.",
"backup.now": "Back up now",
"backup.running": "Backing up…",
"backup.created": "Backup {file} created.",
@@ -646,6 +668,9 @@ export const en = {
"settings.providerName": "Name (optional)",
"settings.providerUp": "reachable · {ms} ms",
"settings.providerDown": "unreachable",
"settings.testConnection": "Test connection",
"settings.testing": "Testing…",
"settings.testOk": "Connection works: {n} models, {ms} ms. Not saved yet.",
"settings.providerAuth": "API key missing or rejected",
"settings.providerCheck": "Check providers",
"settings.providerChecking": "Checking…",
+117 -7
View File
@@ -132,14 +132,120 @@ textarea:focus {
height: 22px;
}
.conn-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--red);
/* --- status LEDs (sidebar) ------------------------------------------------------ */
/* Theme hues, pushed to a vivid chroma and a readable lightness so the state is
obvious in every theme; warn takes the red's lightness/chroma at an amber hue. */
:root {
--led-ok: #22c55e;
--led-warn: #f5a700;
--led-err: #ef4444;
}
.conn-dot.on {
background: var(--green);
@supports (color: oklch(from red l c h)) {
:root {
--led-ok: oklch(from var(--green) clamp(0.7, l, 0.8) max(c, 0.2) clamp(138, h, 158));
--led-warn: oklch(from var(--red) clamp(0.78, l, 0.85) max(c, 0.17) 75);
--led-err: oklch(from var(--red) clamp(0.6, l, 0.7) max(c, 0.21) h);
}
}
.status-leds {
position: relative;
}
.status-leds-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 7px;
border-radius: 999px;
background: var(--bg2);
}
.led {
width: 9px;
height: 9px;
border-radius: 50%;
flex: none;
display: inline-block;
}
.led-ok {
background: var(--led-ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--led-ok) 70%, transparent);
}
.led-warn {
background: var(--led-warn);
box-shadow: 0 0 6px color-mix(in srgb, var(--led-warn) 70%, transparent);
}
.led-err {
background: var(--led-err);
box-shadow: 0 0 7px color-mix(in srgb, var(--led-err) 80%, transparent);
}
.status-leds-btn .led-err {
animation: led-blink 1.6s ease-in-out infinite;
}
.led-off {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--muted);
}
@keyframes led-blink {
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.status-leds-btn .led-err {
animation: none;
}
}
.status-pop {
position: fixed;
z-index: 40;
width: min(320px, calc(100vw - 24px));
background: var(--bg1);
border: 1px solid var(--bg3);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 6px;
font-size: 13px;
}
.status-section + .status-section {
border-top: 1px solid var(--bg3);
margin-top: 4px;
padding-top: 4px;
}
.status-head {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 4px;
font-weight: 600;
color: var(--fg0);
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 25px;
color: var(--fg1);
}
.status-row .led {
width: 7px;
height: 7px;
margin-left: -15px;
}
.status-row .grow {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-row .hint {
white-space: nowrap;
}
.status-foot {
display: flex;
gap: 6px;
border-top: 1px solid var(--bg3);
margin-top: 4px;
padding: 6px 4px 2px;
}
.session-list {
@@ -3413,6 +3519,10 @@ button.danger-solid {
.backup-file .grow {
flex: 1;
}
.backup-file button,
.backup-file .button {
padding: 2px 6px;
}
/* --- automation templates ------------------------------------------------------ */
.job-tpl {
+20 -1
View File
@@ -1,5 +1,24 @@
/** "What's new" entries per version (shown once after an update). */
/**
* "What's new" entries per version (shown once after an update). Add new entries
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
unreleased: {
en: [
"Status LEDs in the sidebar for server, models and MCP – click them for details",
"Images: cheap previews first, then the final image with the best model (Nano Banana Pro refines the chosen preview)",
"Settings → Audit: restore a backup (stored or uploaded)",
"Provider form: test the connection before saving; saving checks the status right away",
"opencode Go works again (session header)",
],
de: [
"Status-LEDs in der Seitenleiste für Server, Modelle und MCP – ein Klick zeigt die Details",
"Bilder: erst günstige Vorschauen, dann das Endbild mit dem besten Modell (Nano Banana Pro verfeinert die gewählte Vorschau)",
"Einstellungen → Audit: Backup wiederherstellen (gespeichert oder hochgeladen)",
"Provider-Formular: Verbindung vor dem Speichern testen; Speichern prüft den Status sofort",
"opencode Go funktioniert wieder (Session-Header)",
],
},
"0.1.0": {
en: [
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",