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:
1 parent
49d404eec7
commit
1f69d63b09
35 files changed
+1156
-88
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user