diff --git a/console/src/api/types.ts b/console/src/api/types.ts index 8dcb9e0c..fdb0850c 100644 --- a/console/src/api/types.ts +++ b/console/src/api/types.ts @@ -87,6 +87,7 @@ export interface GatewayStatus { error?: string; modelCount?: number; detail?: string; + loginRequired?: boolean; } >; localBackends?: Record< diff --git a/console/src/components/provider-health.module.css b/console/src/components/provider-health.module.css new file mode 100644 index 00000000..a2f70cba --- /dev/null +++ b/console/src/components/provider-health.module.css @@ -0,0 +1,315 @@ +/* ─── Animations ───────────────────────────────────────────────────────────── */ + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* ─── Panel shell ──────────────────────────────────────────────────────────── */ + +.panel { + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.08); + background: #fff; + overflow: hidden; +} + +.panelHeader { + padding: 12px 16px 11px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + display: flex; + align-items: center; + justify-content: space-between; +} + +.panelTitle { + font-size: 12.5px; + font-weight: 600; + color: #111827; + letter-spacing: -0.01em; +} + +/* ─── Row ──────────────────────────────────────────────────────────────────── */ + +.row { + display: flex; + flex-direction: column; + padding: 9px 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + border-left: 2.5px solid transparent; + transition: background 0.1s ease; +} + +.row:last-of-type { + border-bottom: none; +} + +.row:hover { + background: rgba(0, 0, 0, 0.012); +} + +.rowWarning { + border-left-color: #f59e0b; + background: rgba(245, 158, 11, 0.03); +} + +.rowWarning:hover { + background: rgba(245, 158, 11, 0.055); +} + +.rowDown { + border-left-color: #ef4444; + background: rgba(239, 68, 68, 0.02); +} + +.rowDown:hover { + background: rgba(239, 68, 68, 0.04); +} + +/* ─── Row top: name line + meta ────────────────────────────────────────────── */ + +.rowTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.nameGroup { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +/* ─── Status dot ───────────────────────────────────────────────────────────── */ + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.dotHealthy { background: #22c55e; box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.15); } +.dotWarning { + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.15); + animation: pulse 2s ease-in-out infinite; +} +.dotDown { + background: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15); + animation: pulse 1.6s ease-in-out infinite; +} +.dotInactive { background: #d1d5db; } + +/* ─── Name + badge ─────────────────────────────────────────────────────────── */ + +.name { + font-size: 12.5px; + font-weight: 600; + color: #111827; + letter-spacing: -0.015em; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge { + font-size: 9px; + font-weight: 600; + padding: 1.5px 5px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.07em; + line-height: 1.5; + white-space: nowrap; + flex-shrink: 0; +} + +.badgeRemote { + background: #eff6ff; + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.18); +} + +.badgeLocal { + background: #f0fdf4; + color: #16a34a; + border: 1px solid rgba(22, 163, 74, 0.18); +} + +/* ─── Meta (right side of name line) ──────────────────────────────────────── */ + +.meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.detail { + font-size: 11px; + color: #6b7280; + font-family: "SFMono-Regular", "JetBrains Mono", "Fira Code", monospace; + white-space: nowrap; +} + +.modelCount { + font-size: 11px; + color: #9ca3af; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* ─── Warning inline action ────────────────────────────────────────────────── */ + +.loginBtn { + font-size: 11.5px; + font-weight: 500; + padding: 3px 9px; + border-radius: 5px; + border: 1px solid rgba(217, 119, 6, 0.45); + background: rgba(255, 251, 235, 0.9); + color: #92400e; + cursor: pointer; + white-space: nowrap; + line-height: 1.5; + transition: background 0.1s ease, border-color 0.1s ease; + flex-shrink: 0; +} + +.loginBtn:hover { + background: #fef3c7; + border-color: #d97706; +} + +.loginBtn:active { + background: #fde68a; +} + +/* ─── Inactive footer ──────────────────────────────────────────────────────── */ + +.inactiveFooter { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.04); + font-size: 11px; + color: #b0b7c3; +} + +.inactiveDot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #e5e7eb; + flex-shrink: 0; +} + +.inactiveNames { + font-family: "SFMono-Regular", "JetBrains Mono", "Fira Code", monospace; + font-size: 10.5px; + color: #c4c9d4; +} + +/* ─── Empty state ──────────────────────────────────────────────────────────── */ + +.panelEmpty { + padding: 20px 16px; + font-size: 13px; + color: #9ca3af; + text-align: center; +} + +/* ─── Dark mode ────────────────────────────────────────────────────────────── */ + +:global(html[data-theme="dark"]) .panel { + background: #111827; + border-color: rgba(255, 255, 255, 0.07); +} + +:global(html[data-theme="dark"]) .panelHeader { + border-bottom-color: rgba(255, 255, 255, 0.05); +} + +:global(html[data-theme="dark"]) .panelTitle { + color: #e5edf7; +} + +:global(html[data-theme="dark"]) .row { + border-bottom-color: rgba(255, 255, 255, 0.04); +} + +:global(html[data-theme="dark"]) .row:hover { + background: rgba(255, 255, 255, 0.02); +} + +:global(html[data-theme="dark"]) .rowWarning { + border-left-color: #d97706; + background: rgba(245, 158, 11, 0.05); +} + +:global(html[data-theme="dark"]) .rowWarning:hover { + background: rgba(245, 158, 11, 0.09); +} + +:global(html[data-theme="dark"]) .rowDown { + border-left-color: #dc2626; + background: rgba(239, 68, 68, 0.04); +} + +:global(html[data-theme="dark"]) .rowDown:hover { + background: rgba(239, 68, 68, 0.07); +} + +:global(html[data-theme="dark"]) .name { + color: #e5edf7; +} + +:global(html[data-theme="dark"]) .badgeRemote { + background: rgba(59, 130, 246, 0.12); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.22); +} + +:global(html[data-theme="dark"]) .badgeLocal { + background: rgba(22, 163, 74, 0.12); + color: #86efac; + border-color: rgba(22, 163, 74, 0.22); +} + +:global(html[data-theme="dark"]) .detail { + color: #6b7280; +} + +:global(html[data-theme="dark"]) .modelCount { + color: #4b5563; +} + +:global(html[data-theme="dark"]) .loginBtn { + background: rgba(245, 158, 11, 0.09); + border-color: rgba(245, 158, 11, 0.3); + color: #fbbf24; +} + +:global(html[data-theme="dark"]) .loginBtn:hover { + background: rgba(245, 158, 11, 0.15); + border-color: rgba(245, 158, 11, 0.5); +} + +:global(html[data-theme="dark"]) .inactiveFooter { + border-top-color: rgba(255, 255, 255, 0.04); + color: #374151; +} + +:global(html[data-theme="dark"]) .inactiveDot { + background: #374151; +} + +:global(html[data-theme="dark"]) .inactiveNames { + color: #374151; +} diff --git a/console/src/components/provider-health.tsx b/console/src/components/provider-health.tsx new file mode 100644 index 00000000..45304e95 --- /dev/null +++ b/console/src/components/provider-health.tsx @@ -0,0 +1,140 @@ +import styles from './provider-health.module.css'; + +const LOCAL_PROVIDER_NAMES = new Set(['ollama', 'lmstudio', 'llamacpp', 'vllm']); + +type HealthStatus = 'healthy' | 'warning' | 'inactive' | 'down'; + +export interface ProviderEntry { + kind?: 'local' | 'remote'; + reachable: boolean; + latencyMs?: number; + error?: string; + modelCount?: number; + detail?: string; + loginRequired?: boolean; +} + +function resolveStatus(name: string, provider: ProviderEntry): HealthStatus { + if (provider.loginRequired) return 'warning'; + if (!provider.reachable) { + const isLocal = provider.kind === 'local' || LOCAL_PROVIDER_NAMES.has(name); + return isLocal ? 'inactive' : 'down'; + } + return 'healthy'; +} + +function isLocalProvider(name: string, provider: ProviderEntry): boolean { + return provider.kind === 'local' || LOCAL_PROVIDER_NAMES.has(name); +} + +const DOT_CLASS: Record = { + healthy: styles.dotHealthy, + warning: styles.dotWarning, + down: styles.dotDown, + inactive: styles.dotInactive, +}; + +interface ProviderRowProps { + name: string; + provider: ProviderEntry; + onLogin: () => void; +} + +function ProviderRow({ name, provider, onLogin }: ProviderRowProps) { + const status = resolveStatus(name, provider); + const isLocal = isLocalProvider(name, provider); + const modelCount = provider.modelCount ?? 0; + + const detail = provider.detail + ? provider.detail + : provider.reachable + ? `${provider.latencyMs ?? 0}ms` + : provider.error || 'unreachable'; + + const rowClass = [ + styles.row, + status === 'warning' ? styles.rowWarning : '', + status === 'down' ? styles.rowDown : '', + ].filter(Boolean).join(' '); + + return ( +
+
+
+ + {name} + + {isLocal ? 'local' : 'remote'} + +
+
+ {detail} + + {modelCount} {modelCount === 1 ? 'model' : 'models'} + + {provider.loginRequired && ( + + )} +
+
+
+ ); +} + +interface ProviderHealthPanelProps { + title: string; + entries: Array<[string, ProviderEntry]>; + onLogin: () => void; +} + +export function ProviderHealthPanel({ title, entries, onLogin }: ProviderHealthPanelProps) { + // Split into active (visible rows) and inactive (collapsed footer) + const activeEntries = entries.filter(([name, p]) => { + const status = resolveStatus(name, p); + return status !== 'inactive'; + }); + + const inactiveEntries = entries.filter(([name, p]) => { + const status = resolveStatus(name, p); + return status === 'inactive'; + }); + + return ( +
+
+ {title} +
+ + {entries.length === 0 ? ( +

No provider health data available.

+ ) : ( + <> + {activeEntries.map(([name, provider]) => ( + + ))} + {inactiveEntries.length > 0 && ( +
+ + + + {inactiveEntries.map(([n]) => n).join(' · ')} + + {' '}not running locally + +
+ )} + + )} +
+ ); +} + +// Named export kept for any direct row usage +export { ProviderRow as ProviderHealthRow }; diff --git a/console/src/components/sidebar/sidebar.test.tsx b/console/src/components/sidebar/sidebar.test.tsx index a0eae72d..9ff7a10c 100644 --- a/console/src/components/sidebar/sidebar.test.tsx +++ b/console/src/components/sidebar/sidebar.test.tsx @@ -516,6 +516,10 @@ describe('AppSidebar', () => { ); const logout = screen.getByRole('button', { name: 'Forget token' }); fireEvent.click(logout); + // The button opens a confirm dialog; click the dialog's confirm action. + // getAllByRole returns [trigger, confirm-button]; we want the last one. + const [, confirmBtn] = screen.getAllByRole('button', { name: 'Forget token' }); + fireEvent.click(confirmBtn); expect(onLogout).toHaveBeenCalledTimes(1); }); diff --git a/console/src/routes/dashboard.tsx b/console/src/routes/dashboard.tsx index 64ddce2d..023b7ce7 100644 --- a/console/src/routes/dashboard.tsx +++ b/console/src/routes/dashboard.tsx @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { fetchOverview } from '../api/client'; import { useAuth } from '../auth'; +import { ProviderHealthPanel } from '../components/provider-health'; import { MetricCard, PageHeader, @@ -58,6 +60,7 @@ const RECENT_SESSION_DEFAULT_DIRECTIONS = { export function DashboardPage() { const auth = useAuth(); + const navigate = useNavigate(); const live = useLiveEvents(auth.token); const overviewQuery = useQuery({ queryKey: ['overview', auth.token], @@ -215,29 +218,11 @@ export function DashboardPage() { - -
- {backendEntries.map(([name, backend]) => ( -
-
- {name} - - {backend.detail || - (backend.reachable - ? `${backend.latencyMs ?? 0}ms` - : backend.error || 'unreachable')} - -
- {backend.modelCount ?? 0} models -
- ))} - {backendEntries.length === 0 ? ( -

- No provider health data is available. -

- ) : null} -
-
+ void navigate({ to: '/config' })} + /> diff --git a/console/src/routes/gateway.test.tsx b/console/src/routes/gateway.test.tsx index 857ff9b8..83f67dd8 100644 --- a/console/src/routes/gateway.test.tsx +++ b/console/src/routes/gateway.test.tsx @@ -156,6 +156,10 @@ describe('GatewayPage', () => { await act(async () => { await flushMicrotasks(); }); + fireEvent.click(screen.getByRole('button', { name: 'Restart' })); + await act(async () => { + await flushMicrotasks(); + }); expect(restartGatewayMock).toHaveBeenCalledWith('test-token'); expect( diff --git a/console/src/routes/gateway.tsx b/console/src/routes/gateway.tsx index 42b3e3f8..9a4ad77f 100644 --- a/console/src/routes/gateway.tsx +++ b/console/src/routes/gateway.tsx @@ -1,4 +1,5 @@ import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { useEffect, useState } from 'react'; import { restartGateway, validateToken } from '../api/client'; import { useAuth } from '../auth'; @@ -11,6 +12,7 @@ import { DialogHeader, DialogTitle, } from '../components/dialog'; +import { ProviderHealthPanel } from '../components/provider-health'; import { useToast } from '../components/toast'; import { BooleanPill, MetricCard, PageHeader, Panel } from '../components/ui'; import { useLiveEvents } from '../hooks/use-live-events'; @@ -79,6 +81,8 @@ export function GatewayPage() { }; }, [auth.token, isRestarting]); + const navigate = useNavigate(); + if (!status) { return
Gateway status is unavailable.
; } @@ -220,37 +224,11 @@ export function GatewayPage() {
- - {providerEntries.length === 0 ? ( -
- No provider health data is available. -
- ) : ( -
- {providerEntries.map(([name, provider]) => ( -
-
- {name} - - {provider.detail || - (provider.reachable - ? `${provider.latencyMs ?? 0}ms` - : provider.error || 'unreachable')} - -
-
- - {provider.modelCount ?? 0} models -
-
- ))} -
- )} -
+ void navigate({ to: '/config' })} + /> {schedulerJobs.length === 0 ? ( diff --git a/src/gateway/gateway-service.ts b/src/gateway/gateway-service.ts index 306e115a..7ceaf2e3 100644 --- a/src/gateway/gateway-service.ts +++ b/src/gateway/gateway-service.ts @@ -872,6 +872,7 @@ function buildGatewayProviderHealth(params: { ? 'Login required' : 'Not authenticated', }), + ...(params.codex.reloginRequired ? { loginRequired: true } : {}), modelCount: dedupeStrings(getDiscoveredCodexModelNames()).length, detail: params.codex.authenticated && !params.codex.reloginRequired diff --git a/src/gateway/gateway-types.ts b/src/gateway/gateway-types.ts index 800b3ef6..9146344f 100644 --- a/src/gateway/gateway-types.ts +++ b/src/gateway/gateway-types.ts @@ -321,6 +321,8 @@ export interface GatewayProviderHealthEntry { error?: string; modelCount?: number; detail?: string; + /** True when the provider requires explicit re-authentication (e.g. expired OAuth token). */ + loginRequired?: boolean; } export interface GatewayPluginCommandSummary {