Skip to content

Commit d9e0bea

Browse files
feat: add LLM status indicator to header (koala73#1528)
Extract LlmStatusIndicator UI from PR koala73#1522 into a standalone PR. Shows a green/red dot in the header indicating LLM provider reachability. Polls /api/llm-health every 60s; hides itself on Vercel (404 fallback). Wire up setupLlmStatusIndicator() in App.init(), which was missing in the original PR (dead code fix). Co-authored-by: Jon Torrez <jrtorrez31337@users.noreply.github.com>
1 parent 15121f2 commit d9e0bea

File tree

5 files changed

+102
-1
lines changed

5 files changed

+102
-1
lines changed

src/App.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ export class App {
383383
unifiedSettings: null,
384384
pizzintIndicator: null,
385385
correlationEngine: null,
386+
llmStatusIndicator: null,
386387
countryBriefPage: null,
387388
countryTimeline: null,
388389
positivePanel: null,
@@ -564,6 +565,7 @@ export class App {
564565
this.eventHandlers.setupPlaybackControl();
565566
this.eventHandlers.setupStatusPanel();
566567
this.eventHandlers.setupPizzIntIndicator();
568+
this.eventHandlers.setupLlmStatusIndicator();
567569
this.eventHandlers.setupExportPanel();
568570

569571
// Correlation engine

src/app/app-context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { CountryTimeline } from '@/components/CountryTimeline';
1313
import type { PlaybackControl } from '@/components';
1414
import type { ExportPanel } from '@/utils';
1515
import type { UnifiedSettings } from '@/components/UnifiedSettings';
16-
import type { PizzIntIndicator } from '@/components';
16+
import type { PizzIntIndicator, LlmStatusIndicator } from '@/components';
1717
import type { ParsedMapUrlState } from '@/utils';
1818
import type { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel';
1919
import type { CountersPanel } from '@/components/CountersPanel';
@@ -107,6 +107,7 @@ export interface AppContext {
107107
unifiedSettings: UnifiedSettings | null;
108108
pizzintIndicator: PizzIntIndicator | null;
109109
correlationEngine: CorrelationEngine | null;
110+
llmStatusIndicator: LlmStatusIndicator | null;
110111
countryBriefPage: CountryBriefPanel | null;
111112
countryTimeline: CountryTimeline | null;
112113

src/app/event-handlers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
PlaybackControl,
99
StatusPanel,
1010
PizzIntIndicator,
11+
LlmStatusIndicator,
1112
CIIPanel,
1213
PredictionPanel,
1314
} from '@/components';
@@ -767,6 +768,14 @@ export class EventHandlerManager implements AppModule {
767768
}
768769
}
769770

771+
setupLlmStatusIndicator(): void {
772+
this.ctx.llmStatusIndicator = new LlmStatusIndicator();
773+
const headerRight = this.ctx.container.querySelector('.header-right');
774+
if (headerRight) {
775+
headerRight.appendChild(this.ctx.llmStatusIndicator.getElement());
776+
}
777+
}
778+
770779
setupExportPanel(): void {
771780
this.ctx.exportPanel = new ExportPanel(() => ({
772781
news: this.ctx.latestClusters.length > 0 ? this.ctx.latestClusters : this.ctx.allNews,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Small header indicator showing LLM provider reachability.
2+
// Polls /api/llm-health every 60s. Shows green dot when available, red when offline.
3+
4+
import { h } from '@/utils/dom-utils';
5+
6+
const POLL_INTERVAL_MS = 60_000;
7+
8+
interface LlmHealthResponse {
9+
available: boolean;
10+
providers: Array<{ name: string; url: string; available: boolean }>;
11+
checkedAt: number;
12+
}
13+
14+
export class LlmStatusIndicator {
15+
private element: HTMLElement;
16+
private dot: HTMLElement;
17+
private label: HTMLElement;
18+
private timer: ReturnType<typeof setInterval> | null = null;
19+
20+
constructor() {
21+
this.dot = h('span', {
22+
style: 'display:inline-block;width:6px;height:6px;border-radius:50%;background:#ff4444;margin-right:4px;',
23+
});
24+
this.label = h('span', {
25+
style: 'font-size:9px;letter-spacing:0.5px;opacity:0.7;',
26+
}, 'LLM');
27+
this.element = h('div', {
28+
className: 'llm-status-indicator',
29+
title: 'LLM provider status — checking...',
30+
style: 'display:flex;align-items:center;padding:0 6px;cursor:default;user-select:none;',
31+
}, this.dot, this.label);
32+
33+
this.poll();
34+
this.timer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
35+
}
36+
37+
private async poll(): Promise<void> {
38+
try {
39+
const resp = await fetch('/api/llm-health', {
40+
signal: AbortSignal.timeout(5_000),
41+
});
42+
if (resp.status === 404) {
43+
// Endpoint only exists in sidecar/Docker — hide indicator on Vercel
44+
this.element.style.display = 'none';
45+
this.destroy();
46+
return;
47+
}
48+
if (!resp.ok) {
49+
this.setStatus(false, 'LLM', 'Health endpoint error');
50+
return;
51+
}
52+
const data: LlmHealthResponse = await resp.json();
53+
const active = data.providers.filter(p => p.available);
54+
// Show the active provider name in the label (first available wins the chain)
55+
const activeName = active.length > 0 ? active[0]!.name.toUpperCase() : '';
56+
const tooltipLines: string[] = [];
57+
for (const p of data.providers) {
58+
tooltipLines.push(`${p.available ? '●' : '○'} ${p.name}${p.available ? 'online' : 'offline'}`);
59+
}
60+
this.setStatus(
61+
data.available,
62+
activeName || 'LLM',
63+
data.available
64+
? `LLM via ${activeName}\n${tooltipLines.join('\n')}`
65+
: `LLM offline — AI features unavailable\n${tooltipLines.join('\n')}`,
66+
);
67+
} catch {
68+
this.setStatus(false, 'LLM', 'LLM health check failed');
69+
}
70+
}
71+
72+
private setStatus(available: boolean, labelText: string, tooltip: string): void {
73+
this.dot.style.background = available ? '#44ff88' : '#ff4444';
74+
this.label.textContent = labelText;
75+
this.element.title = tooltip;
76+
}
77+
78+
public getElement(): HTMLElement {
79+
return this.element;
80+
}
81+
82+
public destroy(): void {
83+
if (this.timer) {
84+
clearInterval(this.timer);
85+
this.timer = null;
86+
}
87+
}
88+
}

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './EconomicPanel';
1717
export * from './SearchModal';
1818
export * from './MobileWarningModal';
1919
export * from './PizzIntIndicator';
20+
export * from './LlmStatusIndicator';
2021
export * from './GdeltIntelPanel';
2122
export * from './LiveNewsPanel';
2223
export * from './LiveWebcamsPanel';

0 commit comments

Comments
 (0)