diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 70c8c0f5c..d0b4a55ef 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -207,13 +207,13 @@ describe('ChicoryTV SPA scaffold', () => {
{
detail: 'SQLite is reachable',
link: null,
- status: 'Healthy',
+ status: 'pass',
title: 'Database'
},
{
detail: 'FFmpeg path is missing',
link: null,
- status: 'Warning',
+ status: 'warn',
title: 'FFmpeg'
}
],
@@ -290,13 +290,45 @@ describe('ChicoryTV SPA scaffold', () => {
expect(await screen.findByText('No on-air channels reported')).toBeInTheDocument();
});
+ it('shows failing health checks with error styling, distinct from neutral info checks', async () => {
+ mockDashboardApi({
+ health: [
+ {
+ detail: 'SQLite is reachable',
+ link: null,
+ status: 'pass',
+ title: 'Database'
+ },
+ {
+ detail: 'FFmpeg path is missing',
+ link: null,
+ status: 'fail',
+ title: 'FFmpeg'
+ },
+ {
+ detail: 'Scheduled maintenance window active',
+ link: null,
+ status: 'info',
+ title: 'Maintenance'
+ }
+ ]
+ });
+
+ const { container } = render();
+
+ expect(await screen.findByText('FFmpeg path is missing')).toBeInTheDocument();
+ expect(screen.getAllByText('1 failing')).toHaveLength(2);
+ expect(container.querySelectorAll('.ctv-health-icon-error')).toHaveLength(1);
+ expect(container.querySelectorAll('.ctv-health-icon-idle').length).toBeGreaterThanOrEqual(1);
+ });
+
it('refreshes health on demand without polling it', async () => {
mockDashboardApi({
health: [
{
detail: 'All checks passed',
link: null,
- status: 'Healthy',
+ status: 'pass',
title: 'System'
}
]
diff --git a/web/src/App.tsx b/web/src/App.tsx
index dc042174f..83c503882 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -495,14 +495,18 @@ function summarizeHealth(healthState: DashboardHealthQueryState): { label: strin
return { label: 'Healthy', status: 'ok' };
}
+// The backend serializes health check status as exactly 'pass' | 'fail' | 'warn' | 'info'
+// (see ErsatzTV.Application/Health/Mapper.cs GetStatus).
function isWarningHealthStatus(status: string): boolean {
- return status.toLowerCase() === 'warning' || status.toLowerCase() === 'degraded';
+ return status.toLowerCase() === 'warn';
}
function isErrorHealthStatus(status: string): boolean {
- const normalized = status.toLowerCase();
+ return status.toLowerCase() === 'fail';
+}
- return normalized === 'error' || normalized === 'failed' || normalized === 'unhealthy';
+function isInfoHealthStatus(status: string): boolean {
+ return status.toLowerCase() === 'info';
}
function healthIconStatus(status: string): HealthStatus {
@@ -514,6 +518,10 @@ function healthIconStatus(status: string): HealthStatus {
return 'warn';
}
+ if (isInfoHealthStatus(status)) {
+ return 'idle';
+ }
+
return 'ok';
}
@@ -525,6 +533,10 @@ function healthIcon(status: string): ReactNode {
}
if (iconStatus === 'warn') {
+ return ;
+ }
+
+ if (iconStatus === 'idle') {
return ;
}
@@ -614,6 +626,7 @@ function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }
actions={
}
variant="secondary"
>
diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts
index 4ea56cc3f..e45c2bc67 100644
--- a/web/src/api/dashboard.ts
+++ b/web/src/api/dashboard.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
@@ -14,7 +14,6 @@ export interface DashboardData {
channelStates: DashboardChannelState[];
mediaSources: DashboardMediaSource[];
playouts: DashboardPlayouts;
- version: DashboardVersion;
}
export type DashboardQueryState =
@@ -38,15 +37,14 @@ type DashboardHealthState =
| { checks: null; error: null; status: 'loading' };
export async function getDashboardData(): Promise {
- const [channels, channelStates, mediaSources, playouts, version] = await Promise.all([
+ const [channels, channelStates, mediaSources, playouts] = await Promise.all([
request('/api/channels'),
request('/api/channels/state'),
request('/api/media-sources'),
- request('/api/playouts'),
- request('/api/version')
+ request('/api/playouts')
]);
- return { channels, channelStates, mediaSources, playouts, version };
+ return { channels, channelStates, mediaSources, playouts };
}
export function getDashboardHealth(): Promise {
@@ -94,10 +92,28 @@ export function useDashboardHealthQuery(): DashboardHealthQueryState {
status: 'loading'
});
+ const activeRef = useRef(true);
+
+ useEffect(() => {
+ activeRef.current = true;
+
+ return () => {
+ activeRef.current = false;
+ };
+ }, []);
+
const loadHealth = useCallback(() => {
getDashboardHealth()
- .then((checks) => setState({ checks, error: null, status: 'success' }))
- .catch((error: unknown) => setState({ checks: null, error: messageFromError(error), status: 'error' }));
+ .then((checks) => {
+ if (activeRef.current) {
+ setState({ checks, error: null, status: 'success' });
+ }
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current) {
+ setState({ checks: null, error: messageFromError(error), status: 'error' });
+ }
+ });
}, []);
const refresh = useCallback(() => {
diff --git a/web/src/shell.css b/web/src/shell.css
index 3b9ca9f9b..fcedceadd 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -442,7 +442,6 @@
}
.ctv-onair-head code,
-.ctv-activity-list code,
.ctv-live-channel-row code {
color: var(--text-secondary);
font-family: var(--font-mono, ui-monospace, monospace);
@@ -516,8 +515,7 @@
gap: var(--space-7, 16px);
}
-.ctv-health-panel,
-.ctv-activity-feed {
+.ctv-health-panel {
display: grid;
}
@@ -535,8 +533,7 @@
border-top: 0;
}
-.ctv-health-icon,
-.ctv-activity-icon {
+.ctv-health-icon {
width: 24px;
height: 24px;
display: inline-flex;
@@ -586,69 +583,6 @@
padding: var(--space-4, 8px) var(--space-6, 12px);
}
-.ctv-activity-row {
- min-height: 44px;
- display: grid;
- grid-template-columns: auto auto minmax(0, 1fr) auto;
- align-items: center;
- gap: var(--space-4, 8px);
- border-top: 1px solid var(--border-hairline);
- padding: 0 var(--space-6, 12px);
-}
-
-.ctv-activity-row:first-child {
- border-top: 0;
-}
-
-.ctv-activity-row > span:nth-child(3) {
- overflow: hidden;
- color: var(--text-primary);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.ctv-activity-row code,
-.ctv-release-toggle code {
- color: var(--text-disabled);
- font-family: var(--font-mono, ui-monospace, monospace);
- font-size: var(--text-2xs, 11px);
-}
-
-.ctv-release-toggle {
- width: 100%;
- display: grid;
- grid-template-columns: auto auto minmax(0, 1fr) auto;
- align-items: center;
- gap: var(--space-4, 8px);
- border: 0;
- background: transparent;
- color: var(--text-primary);
- cursor: pointer;
- padding: 0;
- text-align: left;
-}
-
-.ctv-release-toggle svg:first-child {
- color: var(--action-primary);
-}
-
-.ctv-release-toggle code {
- justify-self: end;
-}
-
-.ctv-release-body {
- display: grid;
- gap: var(--space-3, 6px);
- margin-top: var(--space-6, 12px);
- color: var(--text-secondary);
- font-size: var(--text-xs, 12px);
- line-height: 1.45;
-}
-
-.ctv-release-body p {
- margin: 0;
-}
-
.ctv-live-channel-body {
min-height: 96px;
}