agent-nuvira 2.7.0 โ†’ 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/agents/orchestrator.d.ts.map +1 -1
  2. package/dist/agents/orchestrator.js +17 -3
  3. package/dist/agents/orchestrator.js.map +1 -1
  4. package/dist/app.js +26 -0
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +108 -2
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/config/auth.js +18 -0
  9. package/dist/config/jwt.js +24 -0
  10. package/dist/config/keys.js +14 -0
  11. package/dist/gateway/chat-store.d.ts +32 -5
  12. package/dist/gateway/chat-store.d.ts.map +1 -1
  13. package/dist/gateway/chat-store.js +52 -6
  14. package/dist/gateway/chat-store.js.map +1 -1
  15. package/dist/gateway/registry.d.ts.map +1 -1
  16. package/dist/gateway/registry.js +54 -10
  17. package/dist/gateway/registry.js.map +1 -1
  18. package/dist/inference/model-validator.d.ts.map +1 -1
  19. package/dist/inference/model-validator.js +11 -1
  20. package/dist/inference/model-validator.js.map +1 -1
  21. package/dist/inference/tool-call-utils.d.ts +31 -0
  22. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  23. package/dist/inference/tool-call-utils.js +41 -0
  24. package/dist/inference/tool-call-utils.js.map +1 -1
  25. package/dist/learning/reasoning-trace.d.ts +5 -3
  26. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  27. package/dist/learning/reasoning-trace.js +2 -2
  28. package/dist/learning/reasoning-trace.js.map +1 -1
  29. package/dist/middleware/auth.js +30 -0
  30. package/dist/nlu/conversation-gate.js +1 -1
  31. package/dist/nlu/conversation-gate.js.map +1 -1
  32. package/dist/nlu/entities.d.ts.map +1 -1
  33. package/dist/nlu/entities.js +12 -0
  34. package/dist/nlu/entities.js.map +1 -1
  35. package/dist/nlu/intent.d.ts.map +1 -1
  36. package/dist/nlu/intent.js +83 -15
  37. package/dist/nlu/intent.js.map +1 -1
  38. package/dist/passport.js +34 -0
  39. package/dist/routes/auth.js +42 -0
  40. package/dist/routes/user.js +31 -0
  41. package/dist/server.js +17 -0
  42. package/dist/tools/pipeline-tool.d.ts.map +1 -1
  43. package/dist/tools/pipeline-tool.js +14 -0
  44. package/dist/tools/pipeline-tool.js.map +1 -1
  45. package/dist/tools/release-sync.d.ts +52 -16
  46. package/dist/tools/release-sync.d.ts.map +1 -1
  47. package/dist/tools/release-sync.js +143 -79
  48. package/dist/tools/release-sync.js.map +1 -1
  49. package/dist/web-dashboard/src/App.js +85 -0
  50. package/dist/web-dashboard/src/admin-auth.test.js +186 -0
  51. package/dist/web-dashboard/src/ansi.js +23 -0
  52. package/dist/web-dashboard/src/ansi.test.js +31 -0
  53. package/dist/web-dashboard/src/api-admin-auth.test.js +172 -0
  54. package/dist/web-dashboard/src/api-admin.test.js +65 -0
  55. package/dist/web-dashboard/src/api-hub.test.js +117 -0
  56. package/dist/web-dashboard/src/api.js +1421 -0
  57. package/dist/web-dashboard/src/api.test.js +51 -0
  58. package/dist/web-dashboard/src/artifacts.js +128 -0
  59. package/dist/web-dashboard/src/artifacts.test.js +139 -0
  60. package/dist/web-dashboard/src/components/AdminPanel.js +567 -0
  61. package/dist/web-dashboard/src/components/AdminPanel.test.js +288 -0
  62. package/dist/web-dashboard/src/components/AgentHub.js +1580 -0
  63. package/dist/web-dashboard/src/components/AgentHub.test.js +343 -0
  64. package/dist/web-dashboard/src/components/BedrockOnboarding.js +320 -0
  65. package/dist/web-dashboard/src/components/BenchmarkCharts.js +228 -0
  66. package/dist/web-dashboard/src/components/ChatPage.js +1586 -0
  67. package/dist/web-dashboard/src/components/ChatPage.test.js +899 -0
  68. package/dist/web-dashboard/src/components/ContactsPage.js +141 -0
  69. package/dist/web-dashboard/src/components/CostDashboard.js +105 -0
  70. package/dist/web-dashboard/src/components/DAGView.js +477 -0
  71. package/dist/web-dashboard/src/components/DAGView.test.js +147 -0
  72. package/dist/web-dashboard/src/components/EnvVarEditor.js +138 -0
  73. package/dist/web-dashboard/src/components/EvalsPage.js +73 -0
  74. package/dist/web-dashboard/src/components/EvalsPage.test.js +120 -0
  75. package/dist/web-dashboard/src/components/ExecutionHistory.js +201 -0
  76. package/dist/web-dashboard/src/components/GatewayPage.js +40 -0
  77. package/dist/web-dashboard/src/components/GatewayPage.test.js +74 -0
  78. package/dist/web-dashboard/src/components/HealthPanel.js +68 -0
  79. package/dist/web-dashboard/src/components/HistoryBrowser.js +34 -0
  80. package/dist/web-dashboard/src/components/Layout.js +50 -0
  81. package/dist/web-dashboard/src/components/Markdown.js +152 -0
  82. package/dist/web-dashboard/src/components/Markdown.test.js +126 -0
  83. package/dist/web-dashboard/src/components/MarkdownZeroDep.js +237 -0
  84. package/dist/web-dashboard/src/components/MemoryPanel.js +81 -0
  85. package/dist/web-dashboard/src/components/ModelTimeline.js +298 -0
  86. package/dist/web-dashboard/src/components/ModelsPanel.js +1484 -0
  87. package/dist/web-dashboard/src/components/ModelsPanel.test.js +460 -0
  88. package/dist/web-dashboard/src/components/Overview.js +123 -0
  89. package/dist/web-dashboard/src/components/PhaseTimeline.js +359 -0
  90. package/dist/web-dashboard/src/components/PhaseTimeline.test.js +234 -0
  91. package/dist/web-dashboard/src/components/PlatformConfigSection.js +384 -0
  92. package/dist/web-dashboard/src/components/PlatformConfigSection.test.js +106 -0
  93. package/dist/web-dashboard/src/components/PlatformsPage.js +250 -0
  94. package/dist/web-dashboard/src/components/QuotaPanel.js +159 -0
  95. package/dist/web-dashboard/src/components/QuotaPanel.test.js +85 -0
  96. package/dist/web-dashboard/src/components/RequestsPanel.js +229 -0
  97. package/dist/web-dashboard/src/components/RequestsPanel.test.js +103 -0
  98. package/dist/web-dashboard/src/components/RoutingInsightsPanel.js +938 -0
  99. package/dist/web-dashboard/src/components/RoutingInsightsPanel.test.js +339 -0
  100. package/dist/web-dashboard/src/components/RoutingWalkthrough.js +408 -0
  101. package/dist/web-dashboard/src/components/RoutingWalkthrough.test.js +209 -0
  102. package/dist/web-dashboard/src/components/TaskConsole.js +119 -0
  103. package/dist/web-dashboard/src/components/TaskConsole.test.js +123 -0
  104. package/dist/web-dashboard/src/components/TasksPage.js +256 -0
  105. package/dist/web-dashboard/src/components/TasksPage.test.js +103 -0
  106. package/dist/web-dashboard/src/components/TracePanel.js +306 -0
  107. package/dist/web-dashboard/src/components/WhatsAppPanel.js +195 -0
  108. package/dist/web-dashboard/src/components/WhatsAppPanel.test.js +122 -0
  109. package/dist/web-dashboard/src/jsonOrNull.js +37 -0
  110. package/dist/web-dashboard/src/main.js +15 -0
  111. package/dist/web-dashboard/src/mask.js +11 -0
  112. package/dist/web-dashboard/vite.config.js +23 -0
  113. package/dist/web-dashboard/vitest.config.js +15 -0
  114. package/package.json +5 -3
@@ -0,0 +1,1484 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FlakinessChip = FlakinessChip;
4
+ exports.ContextWindowChip = ContextWindowChip;
5
+ exports.FlakinessSparkline = FlakinessSparkline;
6
+ exports.keyHygieneWarning = keyHygieneWarning;
7
+ exports.dedupeDayEvents = dedupeDayEvents;
8
+ exports.ActionTimelineChart = ActionTimelineChart;
9
+ exports.ActionTelemetryCard = ActionTelemetryCard;
10
+ exports.default = ModelsPanel;
11
+ const react_1 = require("react");
12
+ const jsonOrNull_1 = require("../jsonOrNull");
13
+ // โ”€โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
14
+ const LOCAL_PROVIDERS = new Set(['local', 'lmstudio', 'vllm']);
15
+ /** Future speech/TTS providers โ€” always appear last when implemented */
16
+ const SPEECH_PROVIDERS = new Set([]);
17
+ const STATUS_STYLES = {
18
+ available: { bg: '#0a2e1a', text: '#3fb950', dot: '#3fb950', cardBorder: '#3fb950', cardBg: '#0d2818' },
19
+ limited: { bg: '#2d1f00', text: '#d29922', dot: '#d29922', cardBorder: '#d29922', cardBg: '#1f1700' },
20
+ unavailable: { bg: '#2d0f0f', text: '#f85149', dot: '#f85149', cardBorder: '#f85149', cardBg: '#1f0a0a' },
21
+ };
22
+ const COL_OPTIONS = [3, 4, 5];
23
+ // โ”€โ”€โ”€ Resilient fetch helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
24
+ // The Models page must never die to a single transient network hiccup (browser
25
+ // socket-pool contention with the SSE feed, tab throttling, a slow provider
26
+ // probe, an IPv4/IPv6 race on `localhost`). Each fetch gets a hard
27
+ // AbortController timeout, the two endpoints are fetched INDEPENDENTLY (a
28
+ // failure on one must never hide the other), and network-level failures
29
+ // auto-retry with backoff before the panel ever shows an error.
30
+ /** Hard ceiling for one fetch โ€” the server probes providers with a 5s budget. */
31
+ const FETCH_TIMEOUT_MS = 12_000;
32
+ /** Backoff before retrying a TRANSIENT (network-level) failure. */
33
+ const RETRY_BACKOFF_MS = [300, 700];
34
+ /** Healthy auto-refresh cadence. */
35
+ const POLL_INTERVAL_MS = 60_000;
36
+ /** After a failed load, re-poll this quickly so the panel self-heals. */
37
+ const FAILED_REPOLL_MS = 5_000;
38
+ function sleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+ async function fetchWithTimeout(url, timeoutMs) {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
44
+ try {
45
+ return await fetch(url, { signal: controller.signal });
46
+ }
47
+ finally {
48
+ clearTimeout(timer);
49
+ }
50
+ }
51
+ function isTransientNetworkError(err) {
52
+ // TypeError("Failed to fetch") or an AbortError (a DOMException in browsers,
53
+ // a plain Error with name 'AbortError' in Node/undici) means the server never
54
+ // answered โ€” a transient hiccup worth retrying. HTTP errors / parse failures
55
+ // are definitive (the server answered) and are NOT retried.
56
+ return err instanceof TypeError
57
+ || (err instanceof Error && err.name === 'AbortError');
58
+ }
59
+ /**
60
+ * GET /api/models with timeout + retry. Throws after retries are exhausted.
61
+ * `onTransient` fires whenever a network-level (transient) failure occurred,
62
+ * so the caller can trigger a fast self-healing re-poll. Returns the parsed
63
+ * health payload, or throws "unexpected response" if the server answered with
64
+ * something that isn't a health payload (stale server โ€” definitive, no retry).
65
+ */
66
+ async function fetchHealthWithRetry(onTransient) {
67
+ let lastStatus = null;
68
+ let sawTransient = false;
69
+ for (let attempt = 0;; attempt++) {
70
+ try {
71
+ const res = await fetchWithTimeout('/api/models', FETCH_TIMEOUT_MS);
72
+ if (!res.ok) {
73
+ lastStatus = res.status;
74
+ }
75
+ else {
76
+ const data = await (0, jsonOrNull_1.parseJsonOrNull)(res);
77
+ if (data && Array.isArray(data.providers))
78
+ return data;
79
+ // Answered, but not a health payload โ†’ stale/incompatible dashboard server.
80
+ throw new Error('Model health endpoint returned an unexpected response โ€” is the dashboard server up to date?');
81
+ }
82
+ }
83
+ catch (err) {
84
+ if (!isTransientNetworkError(err))
85
+ throw err;
86
+ sawTransient = true;
87
+ onTransient?.();
88
+ }
89
+ if (attempt >= RETRY_BACKOFF_MS.length) {
90
+ throw new Error(sawTransient
91
+ ? 'Dashboard server unreachable โ€” is it still running? Retrying automaticallyโ€ฆ'
92
+ : `Model health endpoint failed (HTTP ${lastStatus})`);
93
+ }
94
+ await sleep(RETRY_BACKOFF_MS[attempt]);
95
+ }
96
+ }
97
+ /**
98
+ * GET /api/model-registry, best-effort: null on ANY failure, never throws.
99
+ * Retries a transient network failure once; onTransient lets the caller
100
+ * schedule a fast re-poll so the optional section self-heals quickly too.
101
+ */
102
+ async function fetchRegistryBestEffort(onTransient) {
103
+ for (let attempt = 0;; attempt++) {
104
+ try {
105
+ const res = await fetchWithTimeout('/api/model-registry', FETCH_TIMEOUT_MS);
106
+ if (!res.ok)
107
+ return null;
108
+ return await (0, jsonOrNull_1.parseJsonOrNull)(res);
109
+ }
110
+ catch (err) {
111
+ if (!isTransientNetworkError(err) || attempt >= 1)
112
+ return null;
113
+ onTransient?.();
114
+ }
115
+ }
116
+ }
117
+ // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
118
+ function getStatusLabel(status) {
119
+ return status === 'available' ? 'Available' : status === 'limited' ? 'Limited' : 'Unavailable';
120
+ }
121
+ function getStatusBadgeLabel(status) {
122
+ return status === 'available' ? 'Ready' : status === 'limited' ? 'Limited' : 'Down';
123
+ }
124
+ function getProviderIcon(provider) {
125
+ const iconMap = {
126
+ local: '๐Ÿ’ป', groq: '๐ŸŸข', nim: '๐Ÿ”ถ', gemini: '๐Ÿ”ท', openrouter: '๐ŸŸฃ',
127
+ openai: '๐Ÿค–', anthropic: '๐Ÿ”ฎ', mistral: '๐ŸŒ€', cohere: '๐Ÿง ',
128
+ together: '๐ŸŸข', deepinfra: '๐ŸŒ', fireworks: '๐ŸŽ†', perplexity: 'โ“',
129
+ azure: '๐Ÿ”ต', anyscale: '๐Ÿ”ท', lmstudio: '๐ŸŽจ', vllm: 'โšก',
130
+ };
131
+ return iconMap[provider] || '๐Ÿ”Œ';
132
+ }
133
+ function getProviderLabel(provider) {
134
+ const labelMap = {
135
+ local: 'Ollama', groq: 'Groq', nim: 'NVIDIA NIM', gemini: 'Gemini',
136
+ openrouter: 'OpenRouter', openai: 'OpenAI', anthropic: 'Anthropic',
137
+ mistral: 'Mistral', cohere: 'Cohere', together: 'Together AI',
138
+ deepinfra: 'DeepInfra', fireworks: 'Fireworks AI', perplexity: 'Perplexity',
139
+ azure: 'Azure OpenAI', anyscale: 'Anyscale', lmstudio: 'LM Studio',
140
+ vllm: 'vLLM / TGI',
141
+ };
142
+ return labelMap[provider] || provider;
143
+ }
144
+ // โ”€โ”€โ”€ Status Badge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
145
+ function StatusBadge({ status, label }) {
146
+ const s = STATUS_STYLES[status];
147
+ return (<span style={{
148
+ display: 'inline-flex', alignItems: 'center', gap: 6,
149
+ background: s.bg, color: s.text, padding: '3px 10px',
150
+ borderRadius: 12, fontSize: 12, fontWeight: 500,
151
+ border: `1px solid ${s.text}22`,
152
+ }}>
153
+ <span style={{ width: 8, height: 8, borderRadius: '50%', background: s.dot }}/>
154
+ {label}
155
+ </span>);
156
+ }
157
+ // โ”€โ”€โ”€ Action Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
158
+ function ActionBar({ onRefresh, loading }) {
159
+ return (<div className="stats-grid mini" style={{ marginBottom: 16 }}>
160
+ <button onClick={onRefresh} disabled={loading} className="stat-card" style={{
161
+ cursor: loading ? 'not-allowed' : 'pointer',
162
+ opacity: loading ? 0.6 : 1,
163
+ border: '1px solid #30363d',
164
+ justifyContent: 'center',
165
+ fontSize: 13,
166
+ }}>
167
+ {loading ? 'โณ Testing...' : '๐Ÿ”„ Refresh Status'}
168
+ </button>
169
+ <div className="stat-card" style={{ border: '1px solid #30363d' }}>
170
+ <div style={{ fontSize: 13, color: '#8b949e', textAlign: 'center', width: '100%' }}>
171
+ Tests all configured providers and their API keys in real time
172
+ </div>
173
+ </div>
174
+ </div>);
175
+ }
176
+ // โ”€โ”€โ”€ Progress Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
177
+ function ProgressBar({ data }) {
178
+ const total = data.totalModels;
179
+ if (total === 0)
180
+ return null;
181
+ const available = data.available || 0;
182
+ const limited = data.limited || 0;
183
+ const unavailable = data.unavailable || 0;
184
+ return (<div style={{ marginBottom: 16 }}>
185
+ <div style={{
186
+ background: '#0d1117', borderRadius: 8, overflow: 'hidden',
187
+ height: 10, display: 'flex', border: '1px solid #21262d',
188
+ }}>
189
+ {available > 0 && <div style={{ width: `${(available / total) * 100}%`, background: '#3fb950', transition: 'width 0.5s' }} title={`${available} available`}/>}
190
+ {limited > 0 && <div style={{ width: `${(limited / total) * 100}%`, background: '#d29922', transition: 'width 0.5s' }} title={`${limited} limited`}/>}
191
+ {unavailable > 0 && <div style={{ width: `${(unavailable / total) * 100}%`, background: '#f85149', transition: 'width 0.5s' }} title={`${unavailable} unavailable`}/>}
192
+ </div>
193
+ <div style={{ display: 'flex', gap: 16, marginTop: 6, fontSize: 12, color: '#8b949e' }}>
194
+ <span><span style={{ color: '#3fb950' }}>โ—</span> Ready</span>
195
+ <span><span style={{ color: '#d29922' }}>โ—</span> Limited</span>
196
+ <span><span style={{ color: '#f85149' }}>โ—</span> Unavailable</span>
197
+ </div>
198
+ </div>);
199
+ }
200
+ // โ”€โ”€โ”€ Provider Card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
201
+ function ProviderCard({ provider }) {
202
+ const [expanded, setExpanded] = (0, react_1.useState)(false);
203
+ const borderColor = STATUS_STYLES[provider.overallStatus].text;
204
+ const counts = {
205
+ available: provider.models.filter((m) => m.status === 'available').length,
206
+ limited: provider.models.filter((m) => m.status === 'limited').length,
207
+ unavailable: provider.models.filter((m) => m.status === 'unavailable').length,
208
+ };
209
+ return (<div style={{
210
+ background: '#161b22', borderRadius: 12,
211
+ border: `1px solid ${borderColor}44`,
212
+ borderLeft: `4px solid ${borderColor}`,
213
+ marginBottom: 12, overflow: 'hidden',
214
+ }}>
215
+ <div onClick={() => setExpanded(!expanded)} style={{
216
+ padding: '14px 18px', cursor: 'pointer',
217
+ display: 'flex', alignItems: 'center', gap: 14,
218
+ userSelect: 'none',
219
+ }}>
220
+ <span style={{ fontSize: 24 }}>{provider.icon}</span>
221
+ <div style={{ flex: 1 }}>
222
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
223
+ <span style={{ fontSize: 16, fontWeight: 600, color: '#e6edf3' }}>
224
+ {provider.providerLabel}
225
+ </span>
226
+ <StatusBadge status={provider.overallStatus} label={getStatusLabel(provider.overallStatus)}/>
227
+ </div>
228
+ <div style={{ fontSize: 13, color: '#8b949e' }}>
229
+ {provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
230
+ {counts.available > 0 && <span style={{ color: '#3fb950' }}> ยท {counts.available} ready</span>}
231
+ {counts.limited > 0 && <span style={{ color: '#d29922' }}> ยท {counts.limited} limited</span>}
232
+ {counts.unavailable > 0 && <span style={{ color: '#f85149' }}> ยท {counts.unavailable} unavailable</span>}
233
+ </div>
234
+ </div>
235
+ <div style={{ fontSize: 12, color: '#8b949e', textAlign: 'right' }}>
236
+ <div style={{ marginBottom: 2, color: provider.apiConfigured ? '#3fb950' : '#f85149' }}>
237
+ {provider.apiConfigured ? 'โœ… Key set' : 'โŒ No key'}
238
+ </div>
239
+ <div style={{ color: provider.apiAccessible ? '#3fb950' : '#f85149' }}>
240
+ {provider.apiAccessible ? 'โœ… Connected' : 'โŒ Offline'}
241
+ </div>
242
+ </div>
243
+ <span style={{
244
+ color: '#8b949e', fontSize: 18,
245
+ transition: 'transform 0.2s',
246
+ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
247
+ }}>โ–ถ</span>
248
+ </div>
249
+
250
+ {expanded && (<>
251
+ <div style={{
252
+ padding: '10px 18px', background: '#0d1117', fontSize: 13, color: '#8b949e',
253
+ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8,
254
+ borderTop: '1px solid #21262d',
255
+ }}>
256
+ <span>{provider.notes}</span>
257
+ {provider.freeTierInfo && (<span style={{ color: '#d29922' }}>๐ŸŽ {provider.freeTierInfo}</span>)}
258
+ </div>
259
+ <div style={{ overflowX: 'auto', borderTop: '1px solid #21262d' }}>
260
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
261
+ <thead>
262
+ <tr style={{ borderBottom: '1px solid #21262d', color: '#8b949e' }}>
263
+ <th style={{ padding: '8px 18px', textAlign: 'left', fontWeight: 500 }}>Model</th>
264
+ <th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Status</th>
265
+ <th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Quota</th>
266
+ <th style={{ padding: '8px 18px', textAlign: 'left', fontWeight: 500 }}>Details</th>
267
+ </tr>
268
+ </thead>
269
+ <tbody>
270
+ {provider.models.map((model, i) => (<tr key={model.id} style={{
271
+ borderBottom: i < provider.models.length - 1 ? '1px solid #21262d' : 'none',
272
+ background: model.status === 'unavailable' ? '#0d1117' : 'transparent',
273
+ }}>
274
+ <td style={{
275
+ padding: '8px 18px', color: '#e6edf3',
276
+ fontFamily: "'SFMono-Regular', Consolas, monospace",
277
+ fontSize: 12,
278
+ }}>
279
+ <span style={{
280
+ display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
281
+ background: STATUS_STYLES[model.status].dot, marginRight: 8,
282
+ }}/>
283
+ {model.name}
284
+ </td>
285
+ <td style={{ padding: '8px 12px' }}>
286
+ <StatusBadge status={model.status} label={getStatusBadgeLabel(model.status)}/>
287
+ </td>
288
+ <td style={{ padding: '8px 12px', color: '#8b949e', fontSize: 12, fontFamily: "'SFMono-Regular', Consolas, monospace" }}>
289
+ {model.rateLimitRemaining !== undefined
290
+ ? model.rateLimitTotal
291
+ ? `${model.rateLimitRemaining}/${model.rateLimitTotal}`
292
+ : `${model.rateLimitRemaining} left`
293
+ : 'โ€”'}
294
+ </td>
295
+ <td style={{ padding: '8px 18px', color: '#8b949e', fontSize: 12 }}>
296
+ {model.statusReason}
297
+ </td>
298
+ </tr>))}
299
+ </tbody>
300
+ </table>
301
+ </div>
302
+ </>)}
303
+ </div>);
304
+ }
305
+ // โ”€โ”€โ”€ Model Availability Registry Section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
306
+ // The UNIFIED enterprise read store: the exact sub-ms FAISS/JSON snapshot the
307
+ // Auto router consults on every pick. Shows verified / unavailable / parked
308
+ // availability + the quota telemetry (tokens remaining, reset windows) that
309
+ // syncQuota mirrors from the ledger โ€” one card, one source of truth.
310
+ function fmtDuration(ms) {
311
+ if (ms <= 0)
312
+ return 'now';
313
+ const h = Math.floor(ms / 3_600_000);
314
+ const m = Math.floor((ms % 3_600_000) / 60_000);
315
+ if (h > 0)
316
+ return `${h}h ${m}m`;
317
+ if (m > 0)
318
+ return `${m}m`;
319
+ return `${Math.ceil(ms / 1000)}s`;
320
+ }
321
+ function registryStatusStyle(status) {
322
+ if (status === 'verified')
323
+ return { text: '#3fb950', bg: '#0a2e1a', dot: '#3fb950' };
324
+ if (status === 'unavailable')
325
+ return { text: '#f85149', bg: '#2d0f0f', dot: '#f85149' };
326
+ return { text: '#8b949e', bg: '#21262d', dot: '#8b949e' };
327
+ }
328
+ /**
329
+ * P4 M4.4 โ€” mid-stream flakiness chip (violet โธ). Mirrors the CLI's `โธ flaky
330
+ * N%` chip: this model started streaming then died before finishing, so the
331
+ * router scales its reliability down (capped 40%) and it ranks below
332
+ * otherwise-identical healthy models. `rate` is the 0-1 EMA from the registry.
333
+ */
334
+ function FlakinessChip({ rate }) {
335
+ const pct = Math.round(rate * 100);
336
+ return (<span title={`โธ flaky mid-stream ${pct}% โ€” started streaming, died before finish; the router deprioritizes flaky models (P4 M4.4)`} style={{
337
+ marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
338
+ background: '#21122e', border: '1px solid #bc8cff', color: '#bc8cff',
339
+ }}>
340
+ โธ flaky {pct}%
341
+ </span>);
342
+ }
343
+ /**
344
+ * v1.60.1/1.60.2 โ€” live context-window chip (โณ). The provider-advertised
345
+ * input window the model probe recorded into the registry (Ollama /api/tags +
346
+ * /api/show fallback, OpenRouter /models, Gemini inputTokenLimit, NIM
347
+ * max_model_len) โ€” the REAL spec the router's context preflight prefers over
348
+ * static estimates. Mirrors the CLI's `โณ ctx` chip and the Routing Insights
349
+ * preference panel. Renders compact (128K / 1M) with the exact tokens in the
350
+ * tooltip.
351
+ */
352
+ function ContextWindowChip({ tokens }) {
353
+ const compact = tokens >= 1_048_576
354
+ ? `${(tokens / 1_048_576).toFixed(1).replace(/\.0$/, '')}M`
355
+ : tokens >= 1024
356
+ ? `${(tokens / 1024).toFixed(0)}K`
357
+ : `${tokens}`;
358
+ return (<span title={`โณ context window ${tokens.toLocaleString()} tokens โ€” live from the provider's model list (v1.60.x); feeds the router's context preflight`} style={{
359
+ marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
360
+ background: '#0a1e2e', border: '1px solid #58a6ff', color: '#58a6ff',
361
+ }}>
362
+ โณ {compact}
363
+ </span>);
364
+ }
365
+ /**
366
+ * P4 M4.4 โ€” flakiness-over-time mini sparkline (violet). Plots the entry's
367
+ * partialRate EMA trajectory: a trend toward 0 = the provider is HEALING via
368
+ * clean successes (each decay point recorded by recordCall); climbing =
369
+ * flakiness accumulating (each partial bump recorded by recordPartial).
370
+ * Renders only when >= 2 samples exist. Tooltip calls out the direction.
371
+ */
372
+ function FlakinessSparkline({ history }) {
373
+ if (!history || history.length < 2)
374
+ return null;
375
+ const W = 46;
376
+ const H = 14;
377
+ const PAD = 1.5;
378
+ const max = Math.max(0.01, ...history.map((p) => p.rate));
379
+ const pts = history.map((p, i) => {
380
+ const x = PAD + (i / (history.length - 1)) * (W - PAD * 2);
381
+ const y = H - PAD - (p.rate / max) * (H - PAD * 2);
382
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
383
+ }).join(' ');
384
+ const first = history[0].rate;
385
+ const last = history[history.length - 1].rate;
386
+ const healing = last < first;
387
+ const pct = Math.round(last * 100);
388
+ return (<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ marginLeft: 8, verticalAlign: 'middle', cursor: 'default' }} aria-label="Flakiness trend">
389
+ <title>
390
+ {healing
391
+ ? `Flakiness healing โ€” ${pct}% now, trending down (clean successes decay the signal)`
392
+ : `Flakiness climbing โ€” ${pct}% now (recent mid-stream interruptions)`}
393
+ </title>
394
+ <polyline points={pts} fill="none" stroke="#bc8cff" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" opacity={0.9}/>
395
+ <circle cx={W - PAD} cy={H - PAD - (last / max) * (H - PAD * 2)} r={2} fill={healing ? '#3fb950' : '#bc8cff'}/>
396
+ </svg>);
397
+ }
398
+ function RegistryEntryRow({ entry }) {
399
+ const style = registryStatusStyle(entry.status);
400
+ const tokens = entry.remainingTokens >= 0
401
+ ? `${entry.remainingTokens.toLocaleString()} left`
402
+ : 'unlimited';
403
+ return (<tr style={{ borderBottom: '1px solid #21262d' }}>
404
+ <td style={{ padding: '8px 12px', color: '#e6edf3', fontFamily: "'SFMono-Regular', Consolas, monospace", fontSize: 12 }}>
405
+ <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: style.dot, marginRight: 8 }}/>
406
+ {entry.model.length > 32 ? entry.model.slice(0, 29) + 'โ€ฆ' : entry.model}
407
+ {(entry.partialRate ?? 0) > 0 && <FlakinessChip rate={entry.partialRate ?? 0}/>}
408
+ {entry.partialHistory && entry.partialHistory.length >= 2 && <FlakinessSparkline history={entry.partialHistory}/>}
409
+ {entry.parked && (<span style={{
410
+ marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8,
411
+ background: '#2d1616', border: '1px solid #f85149', color: '#f85149',
412
+ }}>โธ parked</span>)}
413
+ {entry.measuredSamples ? (<span style={{
414
+ marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
415
+ background: '#0a2e1a', border: '1px solid #3fb950', color: '#3fb950',
416
+ }}>
417
+ ๐Ÿ“ {entry.measuredInputTokens}โ†’{entry.measuredOutputTokens} tok
418
+ </span>) : (<span style={{
419
+ marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
420
+ background: '#21262d', border: '1px solid #8b949e', color: '#8b949e',
421
+ }}>๐Ÿ“ est</span>)}
422
+ {entry.contextWindowTokens && <ContextWindowChip tokens={entry.contextWindowTokens}/>}
423
+ </td>
424
+ <td style={{ padding: '8px 12px' }}>
425
+ <span style={{
426
+ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, fontWeight: 600,
427
+ background: style.bg, color: style.text, padding: '2px 8px', borderRadius: 6,
428
+ }}>
429
+ {entry.status === 'verified' ? 'โœ“ Verified' : entry.status === 'unavailable' ? 'โœ— Unavailable' : 'โ—Œ Unverified'}
430
+ </span>
431
+ </td>
432
+ <td style={{ padding: '8px 12px', textAlign: 'right', fontFamily: "'SFMono-Regular', Consolas, monospace", fontSize: 12, color: entry.remainingTokens >= 0 && entry.remainingTokens <= 100 ? '#d29922' : '#8b949e' }}>
433
+ {tokens}
434
+ </td>
435
+ <td style={{ padding: '8px 12px', textAlign: 'right', color: '#8b949e', fontSize: 12, whiteSpace: 'nowrap' }}>
436
+ {entry.resetsInMs > 0 ? fmtDuration(entry.resetsInMs) : 'โ€”'}
437
+ </td>
438
+ <td style={{ padding: '8px 12px', textAlign: 'right', color: '#8b949e', fontSize: 12, fontFamily: "'SFMono-Regular', Consolas, monospace" }}>
439
+ {entry.latencyMs !== undefined ? `${entry.latencyMs}ms` : 'โ€”'}
440
+ </td>
441
+ <td style={{ padding: '8px 12px', color: '#6e7681', fontSize: 11, maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
442
+ {entry.lastError || (entry.source ? `learned via ${entry.source}` : '')}
443
+ </td>
444
+ </tr>);
445
+ }
446
+ function RegistryCard({ provider }) {
447
+ const [expanded, setExpanded] = (0, react_1.useState)(false);
448
+ const borderColor = provider.verified > 0 ? '#3fb950' : provider.unavailable > 0 ? '#f85149' : '#8b949e';
449
+ return (<div style={{
450
+ background: '#161b22', borderRadius: 12,
451
+ border: `1px solid ${borderColor}44`,
452
+ borderLeft: `4px solid ${borderColor}`,
453
+ marginBottom: 12, overflow: 'hidden',
454
+ }}>
455
+ <div onClick={() => setExpanded(!expanded)} style={{ padding: '14px 18px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 14, userSelect: 'none' }}>
456
+ <span style={{ fontSize: 24 }}>{getProviderIcon(provider.provider)}</span>
457
+ <div style={{ flex: 1 }}>
458
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
459
+ <span style={{ fontSize: 16, fontWeight: 600, color: '#e6edf3' }}>{getProviderLabel(provider.provider)}</span>
460
+ <span style={{
461
+ fontSize: 11, padding: '2px 8px', borderRadius: 10,
462
+ background: provider.parked > 0 ? '#2d1616' : '#12291a',
463
+ border: `1px solid ${provider.parked > 0 ? '#f85149' : '#238636'}`,
464
+ color: provider.parked > 0 ? '#f85149' : '#3fb950',
465
+ }}>
466
+ {provider.parked > 0 ? `${provider.parked} parked` : 'routable'}
467
+ </span>
468
+ {(provider.flaky ?? 0) > 0 && (<span style={{
469
+ fontSize: 11, padding: '2px 8px', borderRadius: 10,
470
+ background: '#21122e', border: '1px solid #bc8cff', color: '#bc8cff',
471
+ }}>
472
+ โธ {provider.flaky} flaky
473
+ </span>)}
474
+ </div>
475
+ <div style={{ fontSize: 13, color: '#8b949e' }}>
476
+ {provider.verified} verified ยท {provider.unverified} unverified ยท {provider.unavailable} unavailable
477
+ </div>
478
+ </div>
479
+ <span style={{ color: '#8b949e', fontSize: 18, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>โ–ถ</span>
480
+ </div>
481
+ {expanded && (<div style={{ overflowX: 'auto', borderTop: '1px solid #21262d' }}>
482
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
483
+ <thead>
484
+ <tr style={{ borderBottom: '1px solid #21262d', color: '#8b949e' }}>
485
+ <th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Model</th>
486
+ <th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Availability</th>
487
+ <th style={{ padding: '8px 12px', textAlign: 'right', fontWeight: 500 }}>Tokens left</th>
488
+ <th style={{ padding: '8px 12px', textAlign: 'right', fontWeight: 500 }}>Resets in</th>
489
+ <th style={{ padding: '8px 12px', textAlign: 'right', fontWeight: 500 }}>Latency</th>
490
+ <th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Reason / Source</th>
491
+ </tr>
492
+ </thead>
493
+ <tbody>
494
+ {provider.models.map((entry) => (<RegistryEntryRow key={entry.model} entry={entry}/>))}
495
+ </tbody>
496
+ </table>
497
+ </div>)}
498
+ </div>);
499
+ }
500
+ // ISSUE-004: providers with consecutive 401/403 auth failures climbing toward
501
+ // the auto-clear threshold (3). Warns BEFORE the key is cleared so the user can
502
+ // fix it โ€” and confirms the cleanup happened once it's past the threshold.
503
+ function keyHygieneWarning(hygiene) {
504
+ if (!hygiene)
505
+ return null;
506
+ const pending = Object.entries(hygiene.consecutive).filter(([, count]) => count > 0);
507
+ if (pending.length === 0)
508
+ return null;
509
+ return (<div style={{
510
+ background: 'rgba(210, 153, 34, 0.08)', border: '1px solid #9e6a03',
511
+ borderRadius: 10, padding: '10px 14px', marginBottom: 14,
512
+ fontSize: 12, color: '#d29922', display: 'flex', gap: 10, alignItems: 'flex-start',
513
+ }}>
514
+ <span style={{ fontSize: 15, lineHeight: '18px' }}>๐Ÿงน</span>
515
+ <div>
516
+ <div style={{ fontWeight: 600, color: '#e3b341', marginBottom: 2 }}>Key hygiene in progress</div>
517
+ <div>
518
+ {pending.map(([provider, count]) => (<span key={provider} style={{ display: 'inline-block', marginRight: 12 }}>
519
+ <code style={{ color: '#e6edf3' }}>{provider}</code> {count}/{hygiene.threshold} consecutive auth failures
520
+ {count >= hygiene.threshold ? ' โ€” key auto-cleared ๐Ÿšซ' : ' โ€” key will be auto-cleared at the threshold'}
521
+ </span>))}
522
+ </div>
523
+ <div style={{ marginTop: 4, color: '#9e6a03' }}>
524
+ After {hygiene.threshold} consecutive 401/403s the invalid key is removed from config; run{' '}
525
+ <code style={{ color: '#58a6ff' }}>buff config set providers.&lt;provider&gt;.apiKey &lt;real-key&gt;</code> to re-enable.
526
+ </div>
527
+ </div>
528
+ </div>);
529
+ }
530
+ function ModelRegistrySection({ data }) {
531
+ if (!data.enabled) {
532
+ return (<div style={{
533
+ background: '#161b22', borderRadius: 12, border: '1px dashed #30363d',
534
+ padding: '20px 24px', marginTop: 24, textAlign: 'center',
535
+ }}>
536
+ <div style={{ fontSize: 24, marginBottom: 8 }}>๐Ÿ“ฆ</div>
537
+ <div style={{ fontSize: 14, fontWeight: 600, color: '#e6edf3', marginBottom: 4 }}>
538
+ Model Availability Registry
539
+ </div>
540
+ <div style={{ fontSize: 12, color: '#6e7681' }}>
541
+ No registry data yet โ€” run <code style={{ color: '#58a6ff' }}>buff models refresh</code> or use Auto routing;
542
+ the registry learns from real usage and probes.
543
+ </div>
544
+ </div>);
545
+ }
546
+ return (<>
547
+ <h2 className="section-title" style={{ marginTop: 36 }}>๐Ÿ“ฆ Model Availability Registry โ€” the store routing reads</h2>
548
+ <p className="section-description">
549
+ The unified sub-ms FAISS/JSON snapshot the Auto router consults on every pick:
550
+ verified vs unavailable models plus quota telemetry (tokens remaining, reset
551
+ windows) mirrored from the ledger. Each row's <strong>โณ chip</strong> is the
552
+ LIVE provider-advertised context window (v1.60.x) โ€” the real spec the router's
553
+ context preflight uses, recorded by the probe from each provider's model list
554
+ (Ollama /api/tags + /api/show, OpenRouter, Gemini, NIM). State changes during a
555
+ session are reported to the watch daemon and recorded here immediately.
556
+ </p>
557
+
558
+ <div className="stats-grid" style={{ marginBottom: 16 }}>
559
+ <div className="stat-card">
560
+ <span className="stat-icon">๐Ÿ“ฆ</span>
561
+ <div className="stat-body">
562
+ <div className="stat-value">{data.total}</div>
563
+ <div className="stat-label">Tracked models</div>
564
+ </div>
565
+ </div>
566
+ <div className="stat-card">
567
+ <span className="stat-icon">โœ…</span>
568
+ <div className="stat-body">
569
+ <div className="stat-value" style={{ color: '#3fb950' }}>{data.verified}</div>
570
+ <div className="stat-label">Verified</div>
571
+ </div>
572
+ </div>
573
+ <div className="stat-card">
574
+ <span className="stat-icon">โ—Œ</span>
575
+ <div className="stat-body">
576
+ <div className="stat-value" style={{ color: '#8b949e' }}>{data.unverified}</div>
577
+ <div className="stat-label">Unverified</div>
578
+ </div>
579
+ </div>
580
+ <div className="stat-card">
581
+ <span className="stat-icon">โ›”</span>
582
+ <div className="stat-body">
583
+ <div className="stat-value" style={{ color: '#f85149' }}>{data.unavailable}</div>
584
+ <div className="stat-label">Unavailable</div>
585
+ </div>
586
+ </div>
587
+ <div className="stat-card">
588
+ <span className="stat-icon">โธ</span>
589
+ <div className="stat-body">
590
+ <div className="stat-value" style={{ color: '#d29922' }}>{data.parked}</div>
591
+ <div className="stat-label">Quota-parked</div>
592
+ </div>
593
+ </div>
594
+ <div className="stat-card">
595
+ <span className="stat-icon">โธ</span>
596
+ <div className="stat-body">
597
+ <div className="stat-value" style={{ color: '#bc8cff' }}>{data.flaky ?? 0}</div>
598
+ <div className="stat-label">Flaky mid-stream</div>
599
+ </div>
600
+ </div>
601
+ <div className="stat-card">
602
+ <span className="stat-icon">๐Ÿ—‘๏ธ</span>
603
+ <div className="stat-body">
604
+ <div className="stat-value" style={{ color: '#8b949e' }}>{data.deletedLocal ?? 0}</div>
605
+ <div className="stat-label">Deleted locally</div>
606
+ </div>
607
+ </div>
608
+ </div>
609
+
610
+ {keyHygieneWarning(data.keyHygiene)}
611
+
612
+ {data.providers.map((provider) => (<RegistryCard key={provider.provider} provider={provider}/>))}
613
+
614
+ <div style={{ textAlign: 'center', fontSize: 12, color: '#484f58', marginTop: 12 }}>
615
+ Backend snapshot ยท auto-refreshes every 60s
616
+ </div>
617
+ </>);
618
+ }
619
+ // โ”€โ”€โ”€ Learned-from-real-usage Telemetry (per action) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
620
+ // Every LLM call writes through to the health store WITH its action tag (chat /
621
+ // execute / plan / edit / ...). This section shows which provider ร— model each
622
+ // action verified or killed โ€” making the predictive skips routing performs
623
+ // visible: a provider killed by ANY action is skipped by all others.
624
+ const ACTION_ICONS = {
625
+ chat: '๐Ÿ’ฌ', execute: 'โš™๏ธ', plan: '๐Ÿ—บ๏ธ', edit: 'โœ๏ธ', skill: '๐Ÿงฉ', learn: '๐Ÿ“š',
626
+ ci: '๐Ÿ”', doctor: '๐Ÿฉบ', probe: '๐Ÿ”ญ', 'spot-check': '๐Ÿงช', telemetry: '๐Ÿ“ก', usage: '๐Ÿงฎ',
627
+ };
628
+ function actionLabel(action) {
629
+ const map = {
630
+ chat: 'Chat', execute: 'Execute', plan: 'Plan', edit: 'Edit', skill: 'Skill',
631
+ learn: 'Learn', ci: 'CI Review', doctor: 'Doctor Probe', probe: 'Probe',
632
+ 'spot-check': 'Spot-check', telemetry: 'Usage mirror',
633
+ };
634
+ return map[action] || action.charAt(0).toUpperCase() + action.slice(1);
635
+ }
636
+ function actionIcon(action) {
637
+ return ACTION_ICONS[action] || '๐ŸŽฏ';
638
+ }
639
+ function fmtShortTime(ms) {
640
+ if (!ms)
641
+ return 'โ€”';
642
+ return new Date(ms).toLocaleDateString() + ' ' +
643
+ new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
644
+ }
645
+ function ModelLearnChip({ provider, model, reason, killed, transient, partial, streamedChunks }) {
646
+ const isKilled = killed === true;
647
+ const isTransient = transient === true;
648
+ const isPartial = partial === true;
649
+ // Partial gets its own violet signal: distinct from a clean error (transient)
650
+ // because a provider that starts-but-can't-finish is a worse reliability
651
+ // signal โ€” the router deprioritizes flaky mid-stream providers.
652
+ const color = isPartial ? '#bc8cff' : isTransient ? '#d29922' : isKilled ? '#f85149' : '#3fb950';
653
+ const bg = isPartial ? '#21122e' : isTransient ? '#2d1f00' : isKilled ? '#2d0f0f' : '#0a2e1a';
654
+ return (<span title={isPartial
655
+ ? `Mid-stream interruption โ€” started streaming${typeof streamedChunks === 'number' ? ` ~${streamedChunks} chunks in` : ''}, died before finish (P4 M4.4); router deprioritizes flaky mid-stream providers${reason ? ` ยท ${reason}` : ''}`
656
+ : isTransient
657
+ ? `Transient failure โ€” health decayed, no flip${reason ? ` ยท ${reason}` : ''}`
658
+ : isKilled
659
+ ? `Killed by this action โ€” predictively skipped by routing${reason ? ` ยท ${reason}` : ''}`
660
+ : 'Verified by this action โ€” trusted by routing'} style={{
661
+ display: 'inline-flex', alignItems: 'center', gap: 6,
662
+ background: bg, border: `1px solid ${color}`,
663
+ color, padding: '3px 9px', borderRadius: 8, fontSize: 11,
664
+ fontFamily: "'SFMono-Regular', Consolas, monospace", whiteSpace: 'nowrap',
665
+ transition: 'transform 0.15s',
666
+ cursor: 'default',
667
+ }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}>
668
+ <span style={{ opacity: 0.85 }}>{isPartial ? 'โธ' : isTransient ? '~' : isKilled ? 'โœ—' : 'โœ“'}</span>
669
+ {provider}/{model.length > 30 ? model.slice(0, 27) + 'โ€ฆ' : model}
670
+ {(isKilled || isPartial) && reason && (<span style={{ color: `${color}99`, fontSize: 10, fontWeight: 400 }}>ยท {reason}</span>)}
671
+ </span>);
672
+ }
673
+ /**
674
+ * One chip per provider ร— model ร— outcome for a day (latest event wins),
675
+ * ordered killed โ†’ partial โ†’ verified โ†’ transient so the most actionable
676
+ * learning (predictive skips first, then flaky mid-stream providers) surfaces
677
+ * first.
678
+ */
679
+ function dedupeDayEvents(events) {
680
+ const latest = new Map();
681
+ for (const e of events)
682
+ latest.set(`${e.provider}|${e.model}|${e.outcome}`, e);
683
+ const priority = { unavailable: 0, partial: 1, verified: 2, error: 3 };
684
+ return [...latest.values()].sort((a, b) => priority[a.outcome] - priority[b.outcome] || b.at - a.at);
685
+ }
686
+ /** Default scrub position: the most recent day with events (else the last day). */
687
+ function lastDayWithEvents(timeline) {
688
+ for (let i = timeline.length - 1; i >= 0; i--) {
689
+ const b = timeline[i];
690
+ if (b.verified + b.killed + b.transient + (b.partial || 0) > 0)
691
+ return i;
692
+ }
693
+ return Math.max(0, timeline.length - 1);
694
+ }
695
+ function ActionTimelineChart({ timeline }) {
696
+ const [dayIdx, setDayIdx] = (0, react_1.useState)(() => lastDayWithEvents(timeline || []));
697
+ const [playing, setPlaying] = (0, react_1.useState)(false);
698
+ const trackRef = (0, react_1.useRef)(null);
699
+ const draggingRef = (0, react_1.useRef)(false);
700
+ const len = timeline?.length ?? 0;
701
+ const clamped = Math.max(0, Math.min(dayIdx, len - 1));
702
+ // Clamp the selection when the timeline refreshes (60s poll) and resizes.
703
+ (0, react_1.useEffect)(() => {
704
+ setDayIdx((cur) => Math.min(cur, Math.max(0, len - 1)));
705
+ }, [len]);
706
+ // Drag: pointer-down on the track grabs it; window move/up drive the caret.
707
+ const setFromClientX = (0, react_1.useCallback)((clientX) => {
708
+ const el = trackRef.current;
709
+ if (!el || len <= 0)
710
+ return;
711
+ const rect = el.getBoundingClientRect();
712
+ const width = rect.width || 1;
713
+ const frac = Math.min(1, Math.max(0, (clientX - rect.left) / width));
714
+ setDayIdx(Math.min(len - 1, Math.floor(frac * len)));
715
+ }, [len]);
716
+ const handlePointerDown = (e) => {
717
+ draggingRef.current = true;
718
+ setPlaying(false);
719
+ setFromClientX(e.clientX);
720
+ };
721
+ // Register the window listeners once; the drag flag lives in a ref so the
722
+ // listeners don't churn on every caret tick during playback.
723
+ (0, react_1.useEffect)(() => {
724
+ const onMove = (e) => {
725
+ if (draggingRef.current)
726
+ setFromClientX(e.clientX);
727
+ };
728
+ const onUp = () => {
729
+ draggingRef.current = false;
730
+ };
731
+ window.addEventListener('pointermove', onMove);
732
+ window.addEventListener('pointerup', onUp);
733
+ return () => {
734
+ window.removeEventListener('pointermove', onMove);
735
+ window.removeEventListener('pointerup', onUp);
736
+ };
737
+ }, [setFromClientX]);
738
+ // Playback: sweep one day at a time (~300ms/day), then rewind + rest.
739
+ (0, react_1.useEffect)(() => {
740
+ if (!playing || len <= 1)
741
+ return;
742
+ const interval = setInterval(() => {
743
+ setDayIdx((prev) => Math.min(len - 1, prev + 1));
744
+ }, 300);
745
+ return () => clearInterval(interval);
746
+ }, [playing, len]);
747
+ // Reached the last day during playback โ†’ stop and rewind to the start.
748
+ // len > 1 guard keeps a single-day timeline from instantly stop/rewinding
749
+ // (the interval effect already refuses to sweep for len <= 1).
750
+ (0, react_1.useEffect)(() => {
751
+ if (playing && len > 1 && dayIdx >= len - 1) {
752
+ setPlaying(false);
753
+ setDayIdx(0);
754
+ }
755
+ }, [playing, len, dayIdx]);
756
+ if (!timeline || len === 0)
757
+ return null;
758
+ const max = Math.max(1, ...timeline.map((b) => b.verified + b.killed + b.transient + (b.partial || 0)));
759
+ const dayLabel = (day) => new Date(day).toLocaleDateString([], { month: 'short', day: 'numeric' });
760
+ const day = timeline[clamped];
761
+ const chips = day ? dedupeDayEvents(day.events || []) : [];
762
+ return (<div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #21262d' }}>
763
+ <div style={{
764
+ fontSize: 11, fontWeight: 600, color: '#8b949e', marginBottom: 8,
765
+ textTransform: 'uppercase', letterSpacing: 0.4,
766
+ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap',
767
+ }}>
768
+ <span>๐Ÿ“ˆ Learned from real usage โ€” last {len} days</span>
769
+ <span style={{ fontWeight: 400, color: '#6e7681', letterSpacing: 0 }}>
770
+ โ€” drag across days ยท click a day ยท play to sweep
771
+ </span>
772
+ </div>
773
+
774
+ {/* Scrub controls (matches the Run Timeline interaction) */}
775
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
776
+ <button onClick={() => {
777
+ if (playing) {
778
+ setPlaying(false);
779
+ }
780
+ else {
781
+ if (dayIdx >= len - 1)
782
+ setDayIdx(0);
783
+ setPlaying(true);
784
+ }
785
+ }} disabled={len <= 1} aria-label={playing ? 'Pause scrub' : 'Play scrub'} style={{
786
+ background: '#21262d', border: `1px solid ${playing ? '#f85149' : '#30363d'}`,
787
+ color: '#e6edf3', padding: '3px 10px', borderRadius: 6,
788
+ cursor: len <= 1 ? 'not-allowed' : 'pointer', fontSize: 11, fontWeight: 600,
789
+ transition: 'all 0.15s', whiteSpace: 'nowrap',
790
+ opacity: len <= 1 ? 0.5 : 1,
791
+ }}>
792
+ {playing ? 'โธ Pause' : 'โ–ถ Play'}
793
+ </button>
794
+ <span style={{ fontSize: 11, color: '#8b949e', whiteSpace: 'nowrap' }}>
795
+ {dayLabel(day.day)} ยท โœ“ {day.verified} ยท โœ— {day.killed}
796
+ {day.transient > 0 ? ` ยท ~ ${day.transient}` : ''}
797
+ {day.partial > 0 ? ` ยท โธ ${day.partial}` : ''}
798
+ </span>
799
+ <input type="range" min={0} max={Math.max(0, len - 1)} step={1} value={clamped} onChange={(e) => { setPlaying(false); setDayIdx(Number(e.target.value)); }} aria-label="Scrub action timeline" style={{ flex: 1, accentColor: '#58a6ff', cursor: 'pointer', minWidth: 80 }}/>
800
+ </div>
801
+
802
+ {/* Day bars โ€” the scrub track */}
803
+ <div ref={trackRef} onPointerDown={handlePointerDown} style={{
804
+ position: 'relative', display: 'flex', alignItems: 'flex-end',
805
+ gap: 3, height: 64, padding: '0 2px', cursor: 'grab',
806
+ userSelect: 'none', touchAction: 'none',
807
+ }}>
808
+ {timeline.map((b, i) => {
809
+ const total = b.verified + b.killed + b.transient + (b.partial || 0);
810
+ const hVerified = (b.verified / max) * 56;
811
+ const hKilled = (b.killed / max) * 56;
812
+ const hTransient = (b.transient / max) * 56;
813
+ const hPartial = ((b.partial || 0) / max) * 56;
814
+ const isActive = i === clamped;
815
+ return (<div key={b.day} onClick={() => { setPlaying(false); setDayIdx(i); }} title={`${dayLabel(b.day)} โ€” โœ“ ${b.verified} verified ยท โœ— ${b.killed} killed ยท ~ ${b.transient} transient${(b.partial || 0) > 0 ? ` ยท โธ ${b.partial} partial` : ''}`} style={{
816
+ flex: 1, display: 'flex', flexDirection: 'column-reverse',
817
+ alignItems: 'center', gap: 0, cursor: 'pointer',
818
+ }}>
819
+ <div style={{
820
+ position: 'relative', width: '100%', borderRadius: 3,
821
+ overflow: isActive ? 'visible' : 'hidden',
822
+ background: total === 0 ? '#21262d' : 'transparent',
823
+ height: total === 0 ? 4 : 56,
824
+ display: 'flex', flexDirection: 'column-reverse',
825
+ boxShadow: isActive ? '0 0 0 1.5px #58a6ff' : undefined,
826
+ opacity: total === 0 ? 0.5 : 1,
827
+ transition: 'box-shadow 0.15s',
828
+ }}>
829
+ {/* Caret โ€” pinned to the ACTIVE bar's own geometry (gap/padding exact) */}
830
+ {isActive && (<div style={{
831
+ position: 'absolute', top: -3, bottom: -3, width: 2, left: '50%',
832
+ transform: 'translateX(-50%)',
833
+ background: '#58a6ff', borderRadius: 2, pointerEvents: 'none',
834
+ boxShadow: '0 0 8px #58a6ff88', zIndex: 1,
835
+ }}/>)}
836
+ {b.verified > 0 && (<div style={{ height: hVerified, background: '#3fb950', minHeight: 3 }}/>)}
837
+ {b.killed > 0 && (<div style={{ height: hKilled, background: '#f85149', minHeight: 3 }}/>)}
838
+ {b.transient > 0 && (<div style={{ height: hTransient, background: '#d29922', minHeight: 3 }}/>)}
839
+ {b.partial > 0 && (<div style={{ height: hPartial, background: '#bc8cff', minHeight: 3 }}/>)}
840
+ </div>
841
+ <div style={{
842
+ fontSize: 9, color: isActive ? '#58a6ff' : '#6e7681', marginTop: 4,
843
+ whiteSpace: 'nowrap', fontWeight: isActive ? 700 : 400,
844
+ }}>
845
+ {dayLabel(b.day)}
846
+ </div>
847
+ </div>);
848
+ })}
849
+ </div>
850
+ <div style={{ display: 'flex', gap: 14, marginTop: 6, fontSize: 11, color: '#8b949e' }}>
851
+ <span><span style={{ color: '#3fb950' }}>โ– </span> verified</span>
852
+ <span><span style={{ color: '#f85149' }}>โ– </span> killed</span>
853
+ <span><span style={{ color: '#d29922' }}>โ– </span> transient</span>
854
+ <span><span style={{ color: '#bc8cff' }}>โ– </span> partial</span>
855
+ </div>
856
+
857
+ {/* Day detail โ€” the chips for the scrubbed day */}
858
+ <div style={{
859
+ marginTop: 10, background: '#161b22', border: '1px solid #21262d',
860
+ borderRadius: 8, padding: '10px 12px',
861
+ }}>
862
+ <div style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', marginBottom: 6 }}>
863
+ {dayLabel(day.day)}{' '}
864
+ <span style={{ color: '#8b949e', fontWeight: 400 }}>
865
+ โ€” what this action learned that day
866
+ </span>
867
+ </div>
868
+ {chips.length === 0 ? (<div style={{ fontSize: 12, color: '#6e7681' }}>
869
+ No learning recorded that day โ€” nothing verified, killed, or partial.
870
+ </div>) : (<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
871
+ {chips.map((e) => e.outcome === 'unavailable' ? (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model} reason={e.errorType} killed/>) : e.outcome === 'verified' ? (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model}/>) : e.outcome === 'partial' ? (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model} reason={e.errorType} partial streamedChunks={e.streamedChunks}/>) : (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model} reason={e.errorType} transient/>))}
872
+ </div>)}
873
+ </div>
874
+ </div>);
875
+ }
876
+ function ActionTelemetryCard({ entry }) {
877
+ const [expanded, setExpanded] = (0, react_1.useState)(true);
878
+ // Partial mid-stream interruptions are the strongest reliability signal โ€”
879
+ // violet border wins even over killed (a provider that starts-but-can't-
880
+ // finish is worse than one that errors cleanly).
881
+ const borderColor = (entry.partial || 0) > 0 ? '#bc8cff' : entry.killed > 0 ? '#f85149' : entry.verified > 0 ? '#3fb950' : '#d29922';
882
+ return (<div style={{
883
+ background: '#161b22', borderRadius: 12,
884
+ border: `1px solid ${borderColor}33`,
885
+ borderLeft: `4px solid ${borderColor}`,
886
+ marginBottom: 12, overflow: 'hidden',
887
+ }}>
888
+ <div onClick={() => setExpanded(!expanded)} style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12, userSelect: 'none' }}>
889
+ <span style={{ fontSize: 20 }}>{actionIcon(entry.action)}</span>
890
+ <div style={{ flex: 1 }}>
891
+ <div style={{ fontSize: 14, fontWeight: 600, color: '#e6edf3', marginBottom: 2 }}>
892
+ {actionLabel(entry.action)}
893
+ </div>
894
+ <div style={{ fontSize: 12, color: '#8b949e', display: 'flex', gap: 14, flexWrap: 'wrap' }}>
895
+ <span><span style={{ color: '#3fb950' }}>โœ“ {entry.verified}</span> verified</span>
896
+ <span><span style={{ color: '#f85149' }}>โœ— {entry.killed}</span> killed</span>
897
+ {entry.transient > 0 && <span><span style={{ color: '#d29922' }}>~ {entry.transient}</span> transient</span>}
898
+ {(entry.partial || 0) > 0 && <span><span style={{ color: '#bc8cff' }}>โธ {entry.partial}</span> partial</span>}
899
+ </div>
900
+ </div>
901
+ <span style={{ color: '#8b949e', fontSize: 16, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>โ–ถ</span>
902
+ </div>
903
+ {expanded && (<div style={{ padding: '12px 16px', borderTop: '1px solid #21262d', background: '#0d1117' }}>
904
+ {entry.killedModels.length > 0 && (<>
905
+ <div style={{
906
+ fontSize: 11, fontWeight: 600, color: '#f85149', marginBottom: 6,
907
+ textTransform: 'uppercase', letterSpacing: 0.4,
908
+ }}>
909
+ โ›” Killed โ€” skipped predictively by routing
910
+ </div>
911
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 14 }}>
912
+ {entry.killedModels.map((m) => (<ModelLearnChip key={`${m.provider}|${m.model}`} provider={m.provider} model={m.model} reason={m.reason} killed/>))}
913
+ </div>
914
+ </>)}
915
+ {entry.verifiedModels.length > 0 && (<>
916
+ <div style={{
917
+ fontSize: 11, fontWeight: 600, color: '#3fb950', marginBottom: 6,
918
+ textTransform: 'uppercase', letterSpacing: 0.4,
919
+ }}>
920
+ โœ… Verified โ€” trusted by routing
921
+ </div>
922
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
923
+ {entry.verifiedModels.map((m) => (<ModelLearnChip key={`${m.provider}|${m.model}`} provider={m.provider} model={m.model}/>))}
924
+ </div>
925
+ </>)}
926
+ {(entry.partialModels?.length || 0) > 0 && (<>
927
+ <div style={{
928
+ fontSize: 11, fontWeight: 600, color: '#bc8cff', marginBottom: 6,
929
+ textTransform: 'uppercase', letterSpacing: 0.4,
930
+ }}>
931
+ โธ Partial โ€” mid-stream interruption (flaky provider, deprioritized)
932
+ </div>
933
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 14 }}>
934
+ {entry.partialModels.map((m) => (<ModelLearnChip key={`${m.provider}|${m.model}`} provider={m.provider} model={m.model} reason={m.reason} partial streamedChunks={m.streamedChunks}/>))}
935
+ </div>
936
+ </>)}
937
+ {entry.killedModels.length === 0 && entry.verifiedModels.length === 0 && (entry.partialModels?.length || 0) === 0 && (<div style={{ fontSize: 12, color: '#6e7681' }}>
938
+ Only transient failures โ€” health decayed, no model flipped.
939
+ </div>)}
940
+ <ActionTimelineChart timeline={entry.timeline}/>
941
+ </div>)}
942
+ </div>);
943
+ }
944
+ function ActionTelemetrySection({ registry }) {
945
+ const tele = registry.actionTelemetry;
946
+ if (!tele)
947
+ return null;
948
+ if (!tele.enabled) {
949
+ return (<div style={{
950
+ background: '#161b22', borderRadius: 12, border: '1px dashed #30363d',
951
+ padding: '18px 24px', marginTop: 24, textAlign: 'center',
952
+ }}>
953
+ <div style={{ fontSize: 22, marginBottom: 6 }}>๐ŸŽ“</div>
954
+ <div style={{ fontSize: 13, fontWeight: 600, color: '#e6edf3', marginBottom: 4 }}>
955
+ Learned from real usage โ€” per action
956
+ </div>
957
+ <div style={{ fontSize: 12, color: '#6e7681', lineHeight: 1.5 }}>
958
+ No per-action telemetry yet. As you use <strong style={{ color: '#8b949e' }}>chat</strong>,{' '}
959
+ <strong style={{ color: '#8b949e' }}>execute</strong>, <strong style={{ color: '#8b949e' }}>plan</strong>,
960
+ and <strong style={{ color: '#8b949e' }}>edit</strong>, each action's verified / killed provider ร—
961
+ model combos appear here โ€” showing exactly what routing learned from real usage.
962
+ </div>
963
+ </div>);
964
+ }
965
+ return (<>
966
+ <h2 className="section-title" style={{ marginTop: 36 }}>๐ŸŽ“ Learned from real usage โ€” per action</h2>
967
+ <p className="section-description">
968
+ Every LLM call writes through to the health store with its action tag. This panel shows which
969
+ provider ร— model each action <span style={{ color: '#3fb950' }}>verified</span> (routable) or{' '}
970
+ <span style={{ color: '#f85149' }}>killed</span> (predictively skipped) โ€” the exact feed that turns
971
+ &ldquo;fail gemini โ†’ fail nim โ†’ local&rdquo; into &ldquo;straight to local&rdquo;.
972
+ </p>
973
+
974
+ <div className="stats-grid mini" style={{ marginBottom: 16 }}>
975
+ <div className="stat-card">
976
+ <span className="stat-icon">๐Ÿ“Š</span>
977
+ <div className="stat-body">
978
+ <div className="stat-value">{tele.total}</div>
979
+ <div className="stat-label">Telemetry events</div>
980
+ </div>
981
+ </div>
982
+ <div className="stat-card">
983
+ <span className="stat-icon">๐ŸŽฏ</span>
984
+ <div className="stat-body">
985
+ <div className="stat-value">{tele.actions.length}</div>
986
+ <div className="stat-label">Actions learning</div>
987
+ </div>
988
+ </div>
989
+ <div className="stat-card">
990
+ <span className="stat-icon">โฑ๏ธ</span>
991
+ <div className="stat-body">
992
+ <div className="stat-value" style={{ fontSize: 15 }}>{fmtShortTime(tele.updatedAt)}</div>
993
+ <div className="stat-label">Last update</div>
994
+ </div>
995
+ </div>
996
+ </div>
997
+
998
+ {tele.actions.map((a) => <ActionTelemetryCard key={a.action} entry={a}/>)}
999
+
1000
+ <div style={{ textAlign: 'center', fontSize: 12, color: '#484f58', marginTop: 12 }}>
1001
+ All actions share one health store โ€” a provider killed by any action is skipped by all others
1002
+ </div>
1003
+ </>);
1004
+ }
1005
+ // โ”€โ”€โ”€ Section Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1006
+ function SectionHeader({ icon, title, count }) {
1007
+ if (count === 0)
1008
+ return null;
1009
+ return (<h3 style={{
1010
+ fontSize: 15, fontWeight: 600, color: '#e6edf3',
1011
+ margin: '24px 0 12px 0', display: 'flex', alignItems: 'center', gap: 8,
1012
+ }}>
1013
+ <span>{icon}</span> {title}
1014
+ <span style={{
1015
+ fontSize: 12, color: '#8b949e', fontWeight: 400,
1016
+ background: '#161b22', padding: '1px 8px', borderRadius: 8,
1017
+ }}>
1018
+ {count}
1019
+ </span>
1020
+ </h3>);
1021
+ }
1022
+ // โ”€โ”€โ”€ Search Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1023
+ function SearchBar({ value, onChange, totalCount }) {
1024
+ return (<div style={{
1025
+ display: 'flex', alignItems: 'center', gap: 12,
1026
+ marginBottom: 14,
1027
+ }}>
1028
+ <div style={{
1029
+ flex: 1, position: 'relative',
1030
+ display: 'flex', alignItems: 'center',
1031
+ background: '#161b22', borderRadius: 8,
1032
+ border: '1px solid #30363d',
1033
+ transition: 'border-color 0.2s',
1034
+ }}>
1035
+ <span style={{
1036
+ position: 'absolute', left: 12, fontSize: 14, color: '#6e7681',
1037
+ pointerEvents: 'none',
1038
+ }}>๐Ÿ”</span>
1039
+ <input type="text" value={value} onChange={(e) => onChange(e.target.value)} placeholder="Search models by name or provider..." style={{
1040
+ width: '100%', padding: '10px 12px 10px 36px',
1041
+ background: 'transparent', border: 'none',
1042
+ color: '#e6edf3', fontSize: 13,
1043
+ outline: 'none',
1044
+ fontFamily: 'inherit',
1045
+ }} onFocus={(e) => { e.currentTarget.parentElement.style.borderColor = '#58a6ff'; }} onBlur={(e) => { e.currentTarget.parentElement.style.borderColor = '#30363d'; }}/>
1046
+ {value && (<button onClick={() => onChange('')} style={{
1047
+ background: 'none', border: 'none', color: '#6e7681',
1048
+ cursor: 'pointer', padding: '8px 12px', fontSize: 14,
1049
+ lineHeight: 1,
1050
+ }}>โœ•</button>)}
1051
+ </div>
1052
+ <div style={{ fontSize: 12, color: '#8b949e', whiteSpace: 'nowrap' }}>
1053
+ {totalCount} model{totalCount !== 1 ? 's' : ''}
1054
+ </div>
1055
+ </div>);
1056
+ }
1057
+ // โ”€โ”€โ”€ Column Count Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1058
+ function ColToggle({ value, onChange }) {
1059
+ return (<div style={{
1060
+ display: 'flex', alignItems: 'center', gap: 8,
1061
+ fontSize: 12, color: '#8b949e', marginBottom: 14,
1062
+ }}>
1063
+ <span>Columns:</span>
1064
+ {COL_OPTIONS.map((c) => (<button key={c} onClick={() => onChange(c)} style={{
1065
+ padding: '4px 12px', borderRadius: 6,
1066
+ background: value === c ? '#1f6feb' : '#21262d',
1067
+ color: value === c ? '#fff' : '#8b949e',
1068
+ border: `1px solid ${value === c ? '#1f6feb' : '#30363d'}`,
1069
+ cursor: 'pointer', fontSize: 12, fontWeight: value === c ? 600 : 400,
1070
+ transition: 'all 0.15s',
1071
+ }}>
1072
+ {c}
1073
+ </button>))}
1074
+ </div>);
1075
+ }
1076
+ // โ”€โ”€โ”€ Model Table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1077
+ function ModelCell({ model, provider }) {
1078
+ const s = STATUS_STYLES[model.status];
1079
+ const quotaText = model.rateLimitRemaining !== undefined
1080
+ ? model.rateLimitTotal
1081
+ ? `${model.rateLimitRemaining} / ${model.rateLimitTotal}`
1082
+ : `${model.rateLimitRemaining} left`
1083
+ : 'โ€”';
1084
+ return (<td style={{ padding: 10, verticalAlign: 'top' }}>
1085
+ <div style={{
1086
+ background: s.cardBg,
1087
+ border: `1px solid ${s.cardBorder}44`,
1088
+ borderRadius: 10,
1089
+ padding: 14,
1090
+ display: 'flex',
1091
+ flexDirection: 'column',
1092
+ gap: 8,
1093
+ height: '100%',
1094
+ transition: 'all 0.2s ease',
1095
+ position: 'relative',
1096
+ overflow: 'hidden',
1097
+ cursor: 'default',
1098
+ }} onMouseEnter={(e) => {
1099
+ e.currentTarget.style.borderColor = s.cardBorder;
1100
+ e.currentTarget.style.boxShadow = `0 2px 10px ${s.cardBorder}22`;
1101
+ }} onMouseLeave={(e) => {
1102
+ e.currentTarget.style.borderColor = `${s.cardBorder}44`;
1103
+ e.currentTarget.style.boxShadow = 'none';
1104
+ }}>
1105
+ {/* Color accent bar on top */}
1106
+ <div style={{
1107
+ position: 'absolute', top: 0, left: 0, right: 0, height: 3,
1108
+ background: s.cardBorder,
1109
+ opacity: 0.6,
1110
+ }}/>
1111
+
1112
+ {/* Line 1: Model name */}
1113
+ <div style={{
1114
+ fontSize: 13, fontWeight: 600, color: '#e6edf3',
1115
+ fontFamily: "'SFMono-Regular', Consolas, monospace",
1116
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
1117
+ paddingTop: 2,
1118
+ }}>
1119
+ {model.name.length > 28 ? model.name.slice(0, 25) + 'โ€ฆ' : model.name}
1120
+ </div>
1121
+
1122
+ {/* Line 2: Provider */}
1123
+ <div style={{ fontSize: 12, color: '#8b949e' }}>
1124
+ {getProviderIcon(provider)} {getProviderLabel(provider)}
1125
+ </div>
1126
+
1127
+ {/* Line 3: Health status โ€” color box */}
1128
+ <div style={{
1129
+ display: 'inline-flex', alignItems: 'center', gap: 5,
1130
+ background: s.bg, color: s.text,
1131
+ padding: '3px 8px', borderRadius: 6,
1132
+ fontSize: 11, fontWeight: 600,
1133
+ alignSelf: 'flex-start',
1134
+ }}>
1135
+ <span style={{ width: 7, height: 7, borderRadius: '50%', background: s.dot }}/>
1136
+ {model.status === 'available' ? 'Available' : model.status === 'limited' ? 'Limited' : 'Unavailable'}
1137
+ </div>
1138
+
1139
+ {/* Line 4: Token remaining */}
1140
+ <div style={{ fontSize: 11, color: '#6e7681' }}>
1141
+ <span style={{ color: '#8b949e' }}>Tokens:</span>{' '}
1142
+ <span style={{
1143
+ color: model.rateLimitRemaining !== undefined && model.rateLimitRemaining <= 10
1144
+ ? '#d29922' : '#8b949e',
1145
+ fontFamily: "'SFMono-Regular', Consolas, monospace",
1146
+ fontWeight: 500,
1147
+ }}>
1148
+ {quotaText}
1149
+ </span>
1150
+ </div>
1151
+
1152
+ {/* Extra: reason if limited/unavailable */}
1153
+ {model.status !== 'available' && model.statusReason && (<div style={{ fontSize: 10, color: '#6e7681', lineHeight: 1.3, marginTop: 2 }}>
1154
+ {model.statusReason.length > 45 ? model.statusReason.slice(0, 42) + 'โ€ฆ' : model.statusReason}
1155
+ </div>)}
1156
+
1157
+ {/* Quota reset time when parked */}
1158
+ {model.parked && model.resetsInMs !== undefined && model.resetsInMs > 0 && (<div style={{
1159
+ fontSize: 10, color: '#d29922', lineHeight: 1.3, marginTop: 4,
1160
+ padding: '3px 6px', borderRadius: 4,
1161
+ background: '#2d2200', border: '1px solid #d2992244',
1162
+ }}>
1163
+ โณ Resets in {fmtDuration(model.resetsInMs)}
1164
+ </div>)}
1165
+ </div>
1166
+ </td>);
1167
+ }
1168
+ // โ”€โ”€โ”€ Models Table Section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1169
+ function ModelsGrid({ providers, colsPerRow, searchQuery }) {
1170
+ // Flatten all models with their provider info
1171
+ const allModels = [];
1172
+ for (const p of providers) {
1173
+ for (const m of p.models) {
1174
+ allModels.push({ model: m, provider: p.provider });
1175
+ }
1176
+ }
1177
+ if (allModels.length === 0)
1178
+ return null;
1179
+ // Filter by search query
1180
+ let filtered = allModels;
1181
+ if (searchQuery.trim()) {
1182
+ const q = searchQuery.toLowerCase().trim();
1183
+ filtered = allModels.filter(({ model, provider }) => model.name.toLowerCase().includes(q) ||
1184
+ getProviderLabel(provider).toLowerCase().includes(q) ||
1185
+ provider.toLowerCase().includes(q));
1186
+ }
1187
+ // Sort: available first, then limited, then unavailable
1188
+ const statusOrder = { available: 0, limited: 1, unavailable: 2 };
1189
+ filtered.sort((a, b) => statusOrder[a.model.status] - statusOrder[b.model.status]);
1190
+ // Build table rows
1191
+ const rows = [];
1192
+ for (let i = 0; i < filtered.length; i += colsPerRow) {
1193
+ rows.push(filtered.slice(i, i + colsPerRow));
1194
+ }
1195
+ return (<>
1196
+ <h2 className="section-title" style={{ marginTop: 36 }}>๐Ÿ“‹ Model Health Overview</h2>
1197
+ <p className="section-description">
1198
+ All models across all providers, color-coded by health status.
1199
+ Each cell shows: Model ยท Provider ยท Health ยท Token Remaining.
1200
+ </p>
1201
+
1202
+ <div style={{ overflowX: 'auto' }}>
1203
+ <table style={{
1204
+ width: '100%',
1205
+ borderCollapse: 'separate',
1206
+ borderSpacing: 10,
1207
+ tableLayout: 'fixed',
1208
+ }}>
1209
+ <tbody>
1210
+ {rows.length > 0 ? (rows.map((row, ri) => (<tr key={ri}>
1211
+ {row.map(({ model, provider }) => (<ModelCell key={`${provider}-${model.id}`} model={model} provider={provider}/>))}
1212
+ {row.length < colsPerRow && Array.from({ length: colsPerRow - row.length }).map((_, ei) => (<td key={`empty-${ei}`} style={{ padding: 10 }}/>))}
1213
+ </tr>))) : (<tr>
1214
+ <td colSpan={colsPerRow} style={{ textAlign: 'center', padding: 40, color: '#6e7681', fontSize: 13 }}>
1215
+ No models match your search "{searchQuery}"
1216
+ </td>
1217
+ </tr>)}
1218
+ </tbody>
1219
+ </table>
1220
+ </div>
1221
+
1222
+ {searchQuery.trim() && filtered.length > 0 && (<div style={{ textAlign: 'right', fontSize: 11, color: '#6e7681', marginTop: 4 }}>
1223
+ Showing {filtered.length} of {allModels.length} models
1224
+ </div>)}
1225
+ </>);
1226
+ }
1227
+ // โ”€โ”€โ”€ Speech Provider Section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1228
+ function SpeechProviderSection() {
1229
+ return (<div style={{
1230
+ background: '#161b22', borderRadius: 12,
1231
+ border: '1px dashed #30363d',
1232
+ padding: '20px 24px',
1233
+ marginTop: 24,
1234
+ textAlign: 'center',
1235
+ }}>
1236
+ <div style={{ fontSize: 24, marginBottom: 8 }}>๐ŸŽ™๏ธ</div>
1237
+ <div style={{ fontSize: 14, fontWeight: 600, color: '#e6edf3', marginBottom: 4 }}>
1238
+ Speech / TTS Provider
1239
+ </div>
1240
+ <div style={{ fontSize: 12, color: '#6e7681' }}>
1241
+ Speech/TTS provider support coming soon.
1242
+ {' '}<a href="https://github.com/imdheerajKube/agent-nuvira/issues/new" target="_blank" rel="noopener noreferrer" style={{ color: '#58a6ff', textDecoration: 'none', cursor: 'pointer' }} onMouseEnter={(e) => { e.currentTarget.style.textDecoration = 'underline'; }} onMouseLeave={(e) => { e.currentTarget.style.textDecoration = 'none'; }}>
1243
+ Request a provider
1244
+ </a>
1245
+ </div>
1246
+ </div>);
1247
+ }
1248
+ // โ”€โ”€โ”€ Legend โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1249
+ function Legend() {
1250
+ return (<div style={{
1251
+ background: '#0d1117', borderRadius: 10, padding: 14, marginBottom: 20,
1252
+ border: '1px solid #21262d', fontSize: 13, color: '#8b949e',
1253
+ display: 'flex', flexWrap: 'wrap', gap: 20,
1254
+ }}>
1255
+ <div>
1256
+ <div style={{ fontWeight: 600, color: '#e6edf3', marginBottom: 6 }}>Color Coding</div>
1257
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
1258
+ <span><span style={{ color: '#3fb950' }}>โ—</span> <strong style={{ color: '#e6edf3' }}>Green</strong> โ€” Working with rate limit available</span>
1259
+ <span><span style={{ color: '#d29922' }}>โ—</span> <strong style={{ color: '#e6edf3' }}>Amber</strong> โ€” Slow / low rate limit / needs action</span>
1260
+ <span><span style={{ color: '#f85149' }}>โ—</span> <strong style={{ color: '#e6edf3' }}>Red</strong> โ€” API key missing / payment needed / unreachable</span>
1261
+ </div>
1262
+ </div>
1263
+ <div>
1264
+ <div style={{ fontWeight: 600, color: '#e6edf3', marginBottom: 6 }}>Provider Sections</div>
1265
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 3, fontSize: 12 }}>
1266
+ <span>โœ… <strong>Cloud</strong> โ€” Online providers with active API keys</span>
1267
+ <span>๐Ÿ  <strong>Local</strong> โ€” Locally running inference servers</span>
1268
+ <span>โ›” <strong>Unavailable</strong> โ€” Missing keys or unreachable endpoints</span>
1269
+ <span>๐ŸŽ™๏ธ <strong>Speech</strong> โ€” Text-to-speech / speech-to-text providers (coming)</span>
1270
+ </div>
1271
+ </div>
1272
+ </div>);
1273
+ }
1274
+ // โ”€โ”€โ”€ Main Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1275
+ function ModelsPanel() {
1276
+ const [modelsData, setModelsData] = (0, react_1.useState)(null);
1277
+ const [registryData, setRegistryData] = (0, react_1.useState)(null);
1278
+ const [loading, setLoading] = (0, react_1.useState)(true);
1279
+ const [error, setError] = (0, react_1.useState)(null);
1280
+ const [colsPerRow, setColsPerRow] = (0, react_1.useState)(4);
1281
+ const [searchQuery, setSearchQuery] = (0, react_1.useState)('');
1282
+ const mountedRef = (0, react_1.useRef)(true);
1283
+ const retryTimerRef = (0, react_1.useRef)(null);
1284
+ // True when the LAST load hit a transient (network-level) failure โ€” used to
1285
+ // decide whether a fast self-healing re-poll is warranted. Definitive errors
1286
+ // (stale server, persistent HTTP 5xx) fall back to the 60s cadence instead.
1287
+ const transientFailureRef = (0, react_1.useRef)(false);
1288
+ function scheduleRepoll() {
1289
+ if (retryTimerRef.current)
1290
+ clearTimeout(retryTimerRef.current);
1291
+ retryTimerRef.current = setTimeout(() => {
1292
+ if (mountedRef.current)
1293
+ fetchModels();
1294
+ }, FAILED_REPOLL_MS);
1295
+ }
1296
+ async function fetchModels() {
1297
+ setLoading(true);
1298
+ setError(null);
1299
+ transientFailureRef.current = false;
1300
+ try {
1301
+ // Health is REQUIRED (throws after retries); registry/telemetry are
1302
+ // OPTIONAL โ€” an older server (or a plain 404) must hide those sections,
1303
+ // never break the health grid.
1304
+ const [data, registry] = await Promise.all([
1305
+ fetchHealthWithRetry(() => { transientFailureRef.current = true; }),
1306
+ fetchRegistryBestEffort(() => { transientFailureRef.current = true; }),
1307
+ ]);
1308
+ if (!mountedRef.current)
1309
+ return;
1310
+ setModelsData(data);
1311
+ setRegistryData(registry);
1312
+ // Recovered from a transient blip (or the optional registry flapped) โ€”
1313
+ // re-check shortly so the panel settles into the fresh state.
1314
+ if (transientFailureRef.current)
1315
+ scheduleRepoll();
1316
+ }
1317
+ catch (err) {
1318
+ if (!mountedRef.current)
1319
+ return;
1320
+ setError(err instanceof Error && err.message ? err.message : 'Failed to fetch model status');
1321
+ // Self-heal only on TRANSIENT failures: re-poll quickly instead of
1322
+ // waiting the full 60s cadence. Definitive errors (stale server, hard
1323
+ // HTTP failures) are NOT hammered โ€” they wait for the next poll.
1324
+ if (transientFailureRef.current)
1325
+ scheduleRepoll();
1326
+ }
1327
+ finally {
1328
+ if (mountedRef.current)
1329
+ setLoading(false);
1330
+ }
1331
+ }
1332
+ (0, react_1.useEffect)(() => {
1333
+ mountedRef.current = true;
1334
+ fetchModels();
1335
+ const interval = setInterval(fetchModels, POLL_INTERVAL_MS);
1336
+ return () => {
1337
+ mountedRef.current = false;
1338
+ clearInterval(interval);
1339
+ if (retryTimerRef.current)
1340
+ clearTimeout(retryTimerRef.current);
1341
+ };
1342
+ }, []);
1343
+ // Sort providers into sections
1344
+ function sortProviders(data) {
1345
+ const available = [];
1346
+ const local = [];
1347
+ const speech = [];
1348
+ const unavailable = [];
1349
+ const availabilityOrder = { available: 0, limited: 1, unavailable: 2 };
1350
+ for (const p of data.providers) {
1351
+ if (SPEECH_PROVIDERS.has(p.provider)) {
1352
+ speech.push(p);
1353
+ }
1354
+ else if (LOCAL_PROVIDERS.has(p.provider)) {
1355
+ local.push(p);
1356
+ }
1357
+ else if (p.overallStatus === 'available') {
1358
+ available.push(p);
1359
+ }
1360
+ else {
1361
+ unavailable.push(p);
1362
+ }
1363
+ }
1364
+ available.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
1365
+ local.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
1366
+ speech.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
1367
+ unavailable.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
1368
+ return { available, local, speech, unavailable };
1369
+ }
1370
+ return (<>
1371
+ <h2 className="section-title">๐Ÿง  Model Provider Status</h2>
1372
+ <p className="section-description">
1373
+ Real-time health check of all AI providers and their available models.
1374
+ Providers are grouped into sections: Available cloud โ†’ Local โ†’ Unavailable.
1375
+ </p>
1376
+
1377
+ <ActionBar onRefresh={fetchModels} loading={loading}/>
1378
+ <Legend />
1379
+
1380
+ {loading && !modelsData && (<div className="loading-state">
1381
+ <div className="loading-spinner"/>
1382
+ <p>Testing all 17 provider connections...</p>
1383
+ </div>)}
1384
+
1385
+ {error && (<div className="empty-state" style={{ color: '#f85149', border: '1px solid #f8514944', borderRadius: 10, padding: 16, marginBottom: 16 }}>
1386
+ โš ๏ธ {error}
1387
+ </div>)}
1388
+
1389
+ {modelsData && (<>
1390
+ {/* Summary stats cards */}
1391
+ <div className="stats-grid" style={{ marginBottom: 16 }}>
1392
+ <div className="stat-card">
1393
+ <span className="stat-icon">๐Ÿง </span>
1394
+ <div className="stat-body">
1395
+ <div className="stat-value">{modelsData.totalModels}</div>
1396
+ <div className="stat-label">Total Models</div>
1397
+ </div>
1398
+ </div>
1399
+ <div className="stat-card">
1400
+ <span className="stat-icon">โœ…</span>
1401
+ <div className="stat-body">
1402
+ <div className="stat-value" style={{ color: '#3fb950' }}>{modelsData.available}</div>
1403
+ <div className="stat-label">Available</div>
1404
+ </div>
1405
+ </div>
1406
+ <div className="stat-card">
1407
+ <span className="stat-icon">๐ŸŸก</span>
1408
+ <div className="stat-body">
1409
+ <div className="stat-value" style={{ color: '#d29922' }}>{modelsData.limited}</div>
1410
+ <div className="stat-label">Limited</div>
1411
+ </div>
1412
+ </div>
1413
+ <div className="stat-card">
1414
+ <span className="stat-icon">๐Ÿ”ด</span>
1415
+ <div className="stat-body">
1416
+ <div className="stat-value" style={{ color: '#f85149' }}>{modelsData.unavailable}</div>
1417
+ <div className="stat-label">Unavailable</div>
1418
+ </div>
1419
+ </div>
1420
+ <div className="stat-card">
1421
+ <span className="stat-icon">๐Ÿ”Œ</span>
1422
+ <div className="stat-body">
1423
+ <div className="stat-value">{modelsData.providers.length}</div>
1424
+ <div className="stat-label">Providers</div>
1425
+ </div>
1426
+ </div>
1427
+ </div>
1428
+
1429
+ <ProgressBar data={modelsData}/>
1430
+
1431
+ {/* โ”€โ”€ Sectioned Provider Cards โ”€โ”€ */}
1432
+ {(() => {
1433
+ const { available, local, speech, unavailable } = sortProviders(modelsData);
1434
+ return (<>
1435
+ <SectionHeader icon="โœ…" title="Available Cloud Providers" count={available.length}/>
1436
+ {available.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
1437
+
1438
+ <SectionHeader icon="๐Ÿ " title="Local Providers" count={local.length}/>
1439
+ {local.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
1440
+
1441
+ <SectionHeader icon="โ›”" title="Unavailable Providers" count={unavailable.length}/>
1442
+ {unavailable.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
1443
+
1444
+ {/* Speech providers (always last) */}
1445
+ <SectionHeader icon="๐ŸŽ™๏ธ" title="Speech / TTS Providers" count={speech.length}/>
1446
+ {speech.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
1447
+ </>);
1448
+ })()}
1449
+
1450
+ {/* โ”€โ”€ Search + Model Grid โ”€โ”€ */}
1451
+ <SearchBar value={searchQuery} onChange={setSearchQuery} totalCount={modelsData.providers.reduce((s, p) => s + p.models.length, 0)}/>
1452
+ <ColToggle value={colsPerRow} onChange={setColsPerRow}/>
1453
+
1454
+ <ModelsGrid providers={modelsData.providers} colsPerRow={colsPerRow} searchQuery={searchQuery}/>
1455
+
1456
+ {/* Model Availability Registry โ€” the unified store routing reads */}
1457
+ {registryData && <ModelRegistrySection data={registryData}/>}
1458
+
1459
+ {/* Learned-from-real-usage telemetry โ€” per-action verified/killed visibility */}
1460
+ {registryData && <ActionTelemetrySection registry={registryData}/>}
1461
+
1462
+ {/* Older/mismatched server: registry data missing โ€” explain why the
1463
+ sections above are absent instead of showing a blank gap. */}
1464
+ {modelsData && !registryData && (<div style={{
1465
+ background: '#161b22', borderRadius: 12, border: '1px dashed #30363d',
1466
+ padding: '14px 20px', marginTop: 24, fontSize: 12, color: '#8b949e',
1467
+ lineHeight: 1.6,
1468
+ }}>
1469
+ ๐Ÿ“ฆ Registry &amp; telemetry sections are hidden โ€” this dashboard
1470
+ server did not return model-registry data (it may be an{' '}
1471
+ <strong style={{ color: '#e6edf3' }}>older version</strong>).
1472
+ Restart the dashboard from the latest install to see them.
1473
+ </div>)}
1474
+
1475
+ {/* Speech provider coming-soon placeholder */}
1476
+ <SpeechProviderSection />
1477
+
1478
+ <div style={{ textAlign: 'center', fontSize: 12, color: '#484f58', marginTop: 16 }}>
1479
+ Last checked: {new Date(modelsData.lastChecked).toLocaleTimeString()}
1480
+ {' ยท '}Auto-refreshes every 60s
1481
+ </div>
1482
+ </>)}
1483
+ </>);
1484
+ }