Skip to content

Commit cf07d29

Browse files
committed
feat(gui): move startup health into dashboard
1 parent fe49bc6 commit cf07d29

3 files changed

Lines changed: 59 additions & 54 deletions

File tree

gui/src/App.tsx

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ function providersHashForPage(): string {
8080

8181
const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
8282
{ id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid },
83-
{ id: "startup", tkey: "nav.startup", Icon: IconPower },
8483
{ id: "providers", tkey: "nav.providers", Icon: IconServer },
8584
{ id: "models", tkey: "nav.models", Icon: IconBoxes },
8685
{ id: "subagents", tkey: "nav.subagents", Icon: IconBot },
@@ -110,7 +109,6 @@ export default function App() {
110109
const [page, setPageState] = useState<Page>(readPageFromHash);
111110
const [theme, setTheme] = useState<Theme>(readStoredTheme);
112111
const [runtimeVersion, setRuntimeVersion] = useState<string | null>(null);
113-
const [startupAtRisk, setStartupAtRisk] = useState(false);
114112
const { locale, setLocale } = useI18n();
115113
const t = useT();
116114

@@ -154,35 +152,6 @@ export default function App() {
154152
return () => window.removeEventListener("hashchange", onHash);
155153
}, []);
156154

157-
useEffect(() => {
158-
let cancelled = false;
159-
let latestRequest = 0;
160-
const readStartupHealth = async () => {
161-
const request = ++latestRequest;
162-
try {
163-
const res = await fetch(`${API_BASE}/api/startup-health`);
164-
if (!res.ok) {
165-
if (!cancelled && request === latestRequest) setStartupAtRisk(true);
166-
return;
167-
}
168-
const data = await res.json() as { status?: unknown; diagnosticStale?: unknown };
169-
const valid = data.status === "native" || data.status === "protected" || data.status === "at-risk";
170-
if (!cancelled && request === latestRequest) {
171-
setStartupAtRisk(!valid || data.status === "at-risk" || data.diagnosticStale === true);
172-
}
173-
} catch {
174-
// A failed refresh cannot preserve a previously green claim.
175-
if (!cancelled && request === latestRequest) setStartupAtRisk(true);
176-
}
177-
};
178-
void readStartupHealth();
179-
const timer = window.setInterval(() => { void readStartupHealth(); }, 30_000);
180-
return () => {
181-
cancelled = true;
182-
window.clearInterval(timer);
183-
};
184-
}, []);
185-
186155
useEffect(() => {
187156
const rawHash = window.location.hash.replace(/^#\/?/, "");
188157
// Legacy #debug deep links must resolve before generic normalization
@@ -339,9 +308,6 @@ export default function App() {
339308
}}
340309
aria-current={page === id ? "page" : undefined}>
341310
<Icon /> {t(tkey)}
342-
{id === "startup" && startupAtRisk && (
343-
<span className="nav-health-dot" title={t("startup.navRisk")} aria-label={t("startup.navRisk")} />
344-
)}
345311
</button>
346312
))}
347313
</nav>

gui/src/pages/Dashboard.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { formatUptime } from "../formatUptime";
33
import { IconAlert, IconExternal, IconInfo, IconRefresh, IconX } from "../icons";
44
import { Trans } from "../i18n/provider";
55
import { useI18n, type TKey } from "../i18n/shared";
6-
import { settingsPollMayCommit, startupRiskDetailKey } from "../startup-health-ui";
6+
import { settingsPollMayCommit } from "../startup-health-ui";
77
import { formatTokens } from "../format-tokens";
88
import { EmptyState, Select } from "../ui";
99

1010
interface HealthData { status: string; version: string; uptime: number }
11+
type StartupHealthStatus = "native" | "protected" | "at-risk" | "error";
1112
interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean }
1213
interface ModelInfo { id: string; provider: string; owned_by?: string }
1314
interface SettingsData {
@@ -188,6 +189,7 @@ function useModalDialog(open: boolean, triggerRef: RefObject<HTMLButtonElement |
188189
export default function Dashboard({ apiBase }: { apiBase: string }) {
189190
const { locale, t } = useI18n();
190191
const [health, setHealth] = useState<HealthData | null>(null);
192+
const [startupHealth, setStartupHealth] = useState<StartupHealthStatus | null>(null);
191193
const [providers, setProviders] = useState<ProviderInfo[]>([]);
192194
const [models, setModels] = useState<ModelInfo[]>([]);
193195
const [settings, setSettings] = useState<SettingsData | null>(null);
@@ -245,6 +247,29 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
245247
}
246248
}, []);
247249

250+
useEffect(() => {
251+
let cancelled = false;
252+
const readStartupHealth = async () => {
253+
try {
254+
const response = await fetch(`${apiBase}/api/startup-health`);
255+
if (!response.ok) throw new Error("startup health unavailable");
256+
const data = await response.json() as { status?: unknown; diagnosticStale?: unknown };
257+
const status = data.status;
258+
const validStatus = status === "native" || status === "protected" || status === "at-risk";
259+
if (!validStatus) throw new Error("invalid startup health response");
260+
if (!cancelled) setStartupHealth(data.diagnosticStale === true ? "at-risk" : status);
261+
} catch {
262+
if (!cancelled) setStartupHealth("error");
263+
}
264+
};
265+
void readStartupHealth();
266+
const interval = window.setInterval(() => { void readStartupHealth(); }, 30_000);
267+
return () => {
268+
cancelled = true;
269+
window.clearInterval(interval);
270+
};
271+
}, [apiBase]);
272+
248273
useEffect(() => {
249274
const fetchData = async () => {
250275
const settingsRequestEpoch = ++settingsRequestEpochRef.current;
@@ -648,16 +673,19 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
648673
</div>
649674
</div>
650675

651-
{(settings?.startupHealth?.status === "at-risk" || settings?.startupHealth?.diagnosticStale) && (
652-
<div className="notice notice-warn" role="alert" style={{ marginTop: -12, marginBottom: 24 }}>
653-
<IconAlert />
654-
<span>
655-
{settings.startupHealth.diagnosticStale
656-
? t("startup.staleData")
657-
: t(startupRiskDetailKey(settings.startupHealth))}{" "}
658-
<a href="#startup">{t("startup.title")}</a>
676+
{startupHealth && (
677+
<a className="startup-health-bar" href="#startup">
678+
<span className={`dot ${startupHealth === "error" ? "dot-red" : startupHealth === "at-risk" ? "dot-amber" : "dot-green"}`} aria-hidden="true" />
679+
<span className="startup-health-bar__summary">
680+
{t(startupHealth === "error"
681+
? "startup.error"
682+
: startupHealth === "at-risk"
683+
? "startup.summary.atRisk"
684+
: startupHealth === "protected"
685+
? "startup.summary.protected"
686+
: "startup.summary.native")}
659687
</span>
660-
</div>
688+
</a>
661689
)}
662690

663691
{projectConfigWarnings.length > 0 && (

gui/src/styles.css

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,27 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
453453
.stat .value { font-size: var(--text-title); font-weight: var(--weight-semibold); letter-spacing: 0; line-height: var(--leading-tight); }
454454
.stat .value.mono { font-family: var(--font-code); font-size: var(--text-subtitle); }
455455

456+
.startup-health-bar {
457+
min-height: var(--control-lg);
458+
margin: calc(-1 * var(--space-3)) 0 var(--space-6);
459+
padding: 0 var(--space-3);
460+
display: flex;
461+
align-items: center;
462+
gap: var(--space-2);
463+
min-width: 0;
464+
color: var(--muted);
465+
background: var(--hover);
466+
border-block: 1px solid var(--border-soft);
467+
text-decoration: none;
468+
font-size: var(--text-control);
469+
line-height: var(--leading-ui);
470+
transition: background var(--motion-fast), color var(--motion-fast);
471+
}
472+
.startup-health-bar:hover { background: var(--raised); color: var(--text); }
473+
.startup-health-bar:active { transform: translateY(1px); }
474+
.startup-health-bar:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; }
475+
.startup-health-bar__summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
476+
456477
/* ---- dashboard model grid (grouped by provider) ---- */
457478
.model-group-head { display: flex; align-items: baseline; gap: 8px; margin: 0 0 8px; font-size: var(--text-control); font-weight: var(--weight-semibold); color: var(--text); }
458479
.model-group-head .count { font-family: var(--font-code); font-weight: var(--weight-medium); color: var(--faint); font-size: var(--text-label); }
@@ -709,16 +730,6 @@ dialog.modal-overlay::backdrop {
709730
.notice-warn { font-size: var(--text-label); line-height: var(--leading-body); padding: 8px 10px; border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); display: flex; align-items: center; gap: 6px; margin-bottom: 12px; }
710731
.notice-warn svg { width: 14px; height: 14px; flex-shrink: 0; }
711732

712-
/* Startup lifecycle health — compact infrastructure status, not a setup wizard. */
713-
.nav-health-dot {
714-
width: 8px;
715-
height: 8px;
716-
border-radius: var(--radius-round);
717-
background: var(--amber);
718-
box-shadow: 0 0 0 3px var(--amber-soft);
719-
margin-left: auto;
720-
flex: 0 0 auto;
721-
}
722733
.startup-page-sub { margin-bottom: 0; }
723734
.startup-hero { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; }
724735
.startup-hero--safe { border-color: color-mix(in srgb, var(--green) 34%, var(--border)); background: color-mix(in srgb, var(--green-soft) 64%, var(--surface)); }

0 commit comments

Comments
 (0)