diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index d284d47ad..d0b4a55ef 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -22,7 +22,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi(); }); - it('renders the admin shell chrome and loads the design system stylesheet', () => { + it('renders the admin shell chrome and loads the design system stylesheet', async () => { render(); expect(screen.getByRole('img', { name: 'ChicoryTV' })).toBeInTheDocument(); @@ -31,7 +31,7 @@ describe('ChicoryTV SPA scaffold', () => { expect(screen.getByRole('searchbox', { name: 'Search' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument(); - expect(screen.getByText('Healthy')).toBeInTheDocument(); + expect(await screen.findAllByText('Healthy')).toHaveLength(2); expect(designSystemStylesheet).toBe('../../design-system/styles.css'); }); @@ -161,8 +161,8 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByText('On air now')).toBeInTheDocument(); expect(screen.getByText('System health')).toBeInTheDocument(); - expect(screen.getByText('Recent activity')).toBeInTheDocument(); - expect(screen.getByText('Release notes')).toBeInTheDocument(); + expect(screen.queryByText('Recent activity')).not.toBeInTheDocument(); + expect(screen.queryByText('Release notes')).not.toBeInTheDocument(); }); it('renders dashboard cards and stats from live API responses', async () => { @@ -185,9 +185,68 @@ describe('ChicoryTV SPA scaffold', () => { streamingMode: 'MPEG-TS' } ], - collections: [{ id: 10, name: 'Saturday Morning' }], - schedules: [{ id: 20, name: 'Default Schedule' }], - sessions: [{ channelNumber: '5.1' }, { channelNumber: '24' }], + channelStates: [ + { + channelId: 1, + channelNumber: '5.1', + onAir: true, + nowPlaying: { + finishUtc: '2026-07-04T21:30:00Z', + startUtc: '2026-07-04T21:00:00Z', + title: 'Saturday Morning Cartoons' + } + }, + { + channelId: 2, + channelNumber: '24', + onAir: false, + nowPlaying: null + } + ], + health: [ + { + detail: 'SQLite is reachable', + link: null, + status: 'pass', + title: 'Database' + }, + { + detail: 'FFmpeg path is missing', + link: null, + status: 'warn', + title: 'FFmpeg' + } + ], + mediaSources: [ + { + connectionAddress: null, + id: 30, + kind: 'Local', + libraries: [ + { id: 31, kind: 'Movies', name: 'Movies' }, + { id: 32, kind: 'Shows', name: 'Shows' } + ], + name: 'Local' + } + ], + playouts: { + page: [ + { + buildStatus: { + lastBuild: '2026-07-04T20:00:00Z', + message: null, + success: true + }, + channelName: 'Retro Cartoons', + channelNumber: '5.1', + dailyRebuildTime: null, + id: 20, + scheduleKind: 'Classic', + scheduleName: 'Default Schedule' + } + ], + totalCount: 1 + }, version: { apiVersion: 3, appVersion: '26.4.0-test' } }); @@ -196,15 +255,20 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByRole('heading', { name: 'On air now' })).toBeInTheDocument(); expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); expect(screen.getByText('5.1')).toBeInTheDocument(); - expect(screen.getByText('News 24')).toBeInTheDocument(); - expect(screen.getByText('24')).toBeInTheDocument(); - expect(screen.getByText('2 streaming')).toBeInTheDocument(); + expect(screen.queryByText('News 24')).not.toBeInTheDocument(); + expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument(); + expect(screen.getByText('1 on air')).toBeInTheDocument(); + expect(screen.getAllByText('1 warning')).toHaveLength(2); expect(screen.getAllByText('2')).toHaveLength(2); - expect(screen.getAllByText('1')).toHaveLength(2); expect(screen.getByText('26.4.0-test')).toBeInTheDocument(); expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.any(Object)); - expect(window.fetch).toHaveBeenCalledWith('/api/sessions', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/health', expect.any(Object)); expect(window.fetch).toHaveBeenCalledWith('/api/version', expect.any(Object)); + expect(window.fetch).not.toHaveBeenCalledWith('/api/sessions', expect.any(Object)); + expect(window.fetch).not.toHaveBeenCalledWith('/api/schedules', expect.any(Object)); }); it('shows the dashboard loading state while requests are pending', async () => { @@ -215,12 +279,70 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByText('Loading dashboard')).toBeInTheDocument(); }); - it('shows an empty on-air state when the API returns no channels', async () => { - mockDashboardApi({ channels: [] }); + it('shows an empty on-air state when no channel state is on air', async () => { + mockDashboardApi({ + channels: [{ id: 1, name: 'Retro Cartoons', number: '5.1' }], + channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: false, nowPlaying: null }] + }); render(); - expect(await screen.findByText('No channels found')).toBeInTheDocument(); + 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: 'pass', + title: 'System' + } + ] + }); + + render(); + + expect(await screen.findByText('All checks passed')).toBeInTheDocument(); + expect(fetchCount('/api/health')).toBe(1); + + fireEvent.click(screen.getByRole('button', { name: 'Refresh health' })); + + expect(await screen.findByText('All checks passed')).toBeInTheDocument(); + expect(fetchCount('/api/health')).toBe(2); }); it('shows the API error detail when dashboard loading fails', async () => { @@ -305,15 +427,17 @@ function jsonResponse(body: unknown, status = 200): Response { function mockDashboardApi({ channels = [], - collections = [], - schedules = [], - sessions = [], + channelStates = [], + health = [], + mediaSources = [], + playouts = { page: [], totalCount: 0 }, version = { apiVersion: 3, appVersion: '26.4.0' } }: { channels?: unknown[]; - collections?: unknown[]; - schedules?: unknown[]; - sessions?: unknown[]; + channelStates?: unknown[]; + health?: unknown[]; + mediaSources?: unknown[]; + playouts?: unknown; version?: unknown; } = {}) { vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { @@ -323,16 +447,20 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(channels)); } - if (path === '/api/collections') { - return Promise.resolve(jsonResponse(collections)); + if (path === '/api/channels/state') { + return Promise.resolve(jsonResponse(channelStates)); } - if (path === '/api/schedules') { - return Promise.resolve(jsonResponse(schedules)); + if (path === '/api/media-sources') { + return Promise.resolve(jsonResponse(mediaSources)); } - if (path === '/api/sessions') { - return Promise.resolve(jsonResponse(sessions)); + if (path === '/api/playouts') { + return Promise.resolve(jsonResponse(playouts)); + } + + if (path === '/api/health') { + return Promise.resolve(jsonResponse(health)); } if (path === '/api/version') { @@ -342,3 +470,7 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(null, 404)); }); } + +function fetchCount(path: string): number { + return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index ea0ee7c0b..83c503882 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,11 +23,11 @@ import { ListVideo, Plus, Radio, + RefreshCw, Search, Settings, - Sparkles, + TriangleAlert, Tv, - Zap } from 'lucide-react'; import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg'; import { @@ -41,10 +41,17 @@ import { ProgressBar, Spinner, Stat, - StatusDot, - Tag + StatusDot } from './components'; -import { useChannelsQuery, useDashboardQuery, type DashboardChannel } from './api'; +import { + useChannelsQuery, + useDashboardHealthQuery, + useDashboardQuery, + useDashboardVersionQuery, + type DashboardChannel, + type DashboardChannelState, + type DashboardHealthQueryState +} from './api'; import { applyDesignSystemTheme, designSystemThemes, @@ -266,11 +273,15 @@ function ThemeSwitcher({ function Sidebar({ activeRoute, + healthState, onNavigate }: { activeRoute: ScreenRoute | null; + healthState: DashboardHealthQueryState; onNavigate: (route: ScreenRoute, event: MouseEvent) => void; }) { + const healthSummary = summarizeHealth(healthState); + return ( ); } +function SidebarVersion() { + const versionQuery = useDashboardVersionQuery(); + + if (versionQuery.status === 'success') { + return {versionQuery.version.appVersion ?? 'version unknown'}; + } + + if (versionQuery.status === 'error') { + return version unavailable; + } + + return loading version; +} + function EndpointRow({ icon, label, url }: { icon: ReactNode; label: string; url: string }) { const [copied, setCopied] = useState(false); @@ -445,20 +470,107 @@ function TopBar({ route }: { route: ScreenRoute | null }) { ); } -function progressForChannel(channel: DashboardChannel, index: number): number { - return ((channel.id * 17 + index * 19) % 72) + 18; +type HealthStatus = 'error' | 'idle' | 'live' | 'ok' | 'warn'; + +function summarizeHealth(healthState: DashboardHealthQueryState): { label: string; status: HealthStatus } { + if (healthState.status === 'loading') { + return { label: 'Checking', status: 'idle' }; + } + + if (healthState.status === 'error') { + return { label: 'Health unavailable', status: 'error' }; + } + + const failedCount = healthState.checks.filter((check) => isErrorHealthStatus(check.status)).length; + const warningCount = healthState.checks.filter((check) => isWarningHealthStatus(check.status)).length; + + if (failedCount > 0) { + return { label: `${failedCount} failing`, status: 'error' }; + } + + if (warningCount > 0) { + return { label: `${warningCount} warning${warningCount === 1 ? '' : 's'}`, status: 'warn' }; + } + + return { label: 'Healthy', status: 'ok' }; } -function programForChannel(channel: DashboardChannel): string { - const profile = channel.fFmpegProfile ?? channel.streamingMode ?? 'Default profile'; - - return `${profile} playout stream`; +// 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() === 'warn'; } -function OnAirCard({ channel, index }: { channel: DashboardChannel; index: number }) { - const name = channel.name ?? 'Unnamed channel'; - const number = channel.number ?? `${channel.id}`; - const progress = progressForChannel(channel, index); +function isErrorHealthStatus(status: string): boolean { + return status.toLowerCase() === 'fail'; +} + +function isInfoHealthStatus(status: string): boolean { + return status.toLowerCase() === 'info'; +} + +function healthIconStatus(status: string): HealthStatus { + if (isErrorHealthStatus(status)) { + return 'error'; + } + + if (isWarningHealthStatus(status)) { + return 'warn'; + } + + if (isInfoHealthStatus(status)) { + return 'idle'; + } + + return 'ok'; +} + +function healthIcon(status: string): ReactNode { + const iconStatus = healthIconStatus(status); + + if (iconStatus === 'error') { + return