- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
9.5 KiB
TypeScript
244 lines
9.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { get, post } from "../api";
|
|
import { fmtCost, fmtPercent, useT, type Key } from "../i18n";
|
|
import { navigate } from "../router";
|
|
import { useStore } from "../store";
|
|
import { Icon } from "./Icon";
|
|
import { Bar, type Plan } from "./Budget";
|
|
|
|
type Level = "ok" | "warn" | "err" | "off";
|
|
|
|
interface ProviderState {
|
|
state: "ok" | "auth" | "down" | "unknown";
|
|
latency_ms?: number;
|
|
checked_at?: number;
|
|
error?: string | null;
|
|
models: number;
|
|
plan?: Plan | null;
|
|
}
|
|
interface McpEntry {
|
|
state: "connected" | "connecting" | "failed";
|
|
tools?: number;
|
|
error?: string | null;
|
|
}
|
|
interface Meter {
|
|
key: string;
|
|
provider: string;
|
|
kind: "total" | "provider" | "images" | "5h" | "week" | "month";
|
|
used: number;
|
|
limit: number;
|
|
ratio: number;
|
|
}
|
|
interface Overview {
|
|
version: string;
|
|
providers: Record<string, ProviderState>;
|
|
mcp: Record<string, McpEntry>;
|
|
/** Budgets and plan windows (admins only). */
|
|
budgets?: Meter[];
|
|
currency?: string;
|
|
}
|
|
|
|
const meterLevel = (m: Meter): Level => (m.ratio >= 1 ? "err" : m.ratio >= 0.8 ? "warn" : "ok");
|
|
|
|
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";
|
|
}
|
|
|
|
/** The fullest window of a subscription plan (5 hours, week, month). */
|
|
const fullest = (plan?: Plan | null) => plan?.windows.reduce((a, b) => (b.ratio > a.ratio ? b : a));
|
|
|
|
const providerLevel = (p: ProviderState): Level => {
|
|
const health: Level = p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err";
|
|
const w = fullest(p.plan);
|
|
// A used-up plan blocks requests just like a rejected key.
|
|
const plan: Level = !w ? "ok" : w.ratio >= 1 ? "err" : w.ratio >= 0.8 ? "warn" : "ok";
|
|
return health === "err" || plan === "err" ? "err" : health === "warn" || plan === "warn" ? "warn" : "ok";
|
|
};
|
|
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)}
|
|
{fullest(p.plan) && ` · ${t("status.plan", { pct: fmtPercent(fullest(p.plan)!.ratio), window: t(`budget.window.${fullest(p.plan)!.window}` as Key) })}`}
|
|
</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>
|
|
|
|
{!!data?.budgets?.length && (
|
|
<div className="status-section">
|
|
<div className="status-head">
|
|
<Led level={combine(data.budgets.map(meterLevel))} /> {t("status.budgets")}
|
|
</div>
|
|
{data.budgets.map((m) => (
|
|
<div key={m.key} className="status-row status-meter" title={`${fmtCost(m.used, data.currency ?? "USD")} / ${fmtCost(m.limit, data.currency ?? "USD")}`}>
|
|
<Led level={meterLevel(m)} />
|
|
<span className="grow">{m.kind === "total" ? t("stats.total") : m.kind === "images" ? t("nav.images") : m.kind === "provider" ? m.provider : `${m.provider} · ${t(`budget.window.${m.kind}` as Key)}`}</span>
|
|
<Bar share={m.ratio} warning={m.ratio >= 0.8} exceeded={m.ratio >= 1} />
|
|
<span className="hint pct">{fmtPercent(m.ratio)}</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>
|
|
);
|
|
}
|