diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e8e051a60..02ba2eddf 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -34,7 +34,9 @@ describe('ChicoryTV SPA scaffold', () => { // The Dashboard declares no primaryAction, so the TopBar renders no primary-action button // here (issue #238 — the old code rendered a dead "Add Channel" button on every screen). expect(screen.queryByRole('button', { name: 'Add Channel' })).not.toBeInTheDocument(); - expect(await screen.findAllByText('Healthy')).toHaveLength(2); + const sidebar = screen.getByRole('complementary'); + expect(await within(sidebar).findByText('Healthy')).toBeInTheDocument(); + expect(await within(sidebar).findByText('26.4.0')).toBeInTheDocument(); expect(designSystemStylesheet).toBe('../../design-system/styles.css'); }); @@ -337,119 +339,15 @@ describe('ChicoryTV SPA scaffold', () => { ); }); - it('renders routed placeholder cards for the downstream screen work', async () => { + it('routes to the extracted Dashboard screen and mounts its query (#246)', async () => { + window.history.replaceState(null, '', '/app/channels'); render(); - expect(await screen.findByText('On air now')).toBeInTheDocument(); - expect(screen.getByText('System health')).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 () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - id: 1, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - streamingMode: 'HLS Direct' - }, - { - ffmpegProfile: 'MPEG-TS', - id: 2, - language: 'fr', - name: 'News 24', - number: '24', - streamingMode: 'MPEG-TS' - } - ], - 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' } - }); - - render(); + fireEvent.click(screen.getByRole('link', { name: 'Dashboard' })); 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.queryByText('News 24')).not.toBeInTheDocument(); - expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); - expect(screen.getByText('1 on air')).toBeInTheDocument(); - expect(screen.getAllByText('1 warning')).toHaveLength(2); - expect(screen.getAllByText('2')).toHaveLength(2); - expect(screen.getByText('26.4.0-test')).toBeInTheDocument(); + expect(window.location.pathname).toBe('/app'); expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels', expect.any(Object)); - expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object)); - expect(window.fetch).toHaveBeenCalledWith('/api/v1/media-sources', expect.any(Object)); - expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts', expect.any(Object)); - expect(window.fetch).toHaveBeenCalledWith('/api/v1/health', expect.any(Object)); - expect(window.fetch).toHaveBeenCalledWith('/api/v1/version', expect.any(Object)); - expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/sessions', expect.any(Object)); - expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/schedules', expect.any(Object)); }); it('renders the extracted Channels screen from live channel APIs (#244)', async () => { @@ -786,100 +684,6 @@ describe('ChicoryTV SPA scaffold', () => { expect(window.location.pathname).toBe('/app/playouts'); }); - it('shows the dashboard loading state while requests are pending', async () => { - vi.spyOn(window, 'fetch').mockImplementation(() => new Promise(() => {})); - - render(); - - expect(await screen.findByText('Loading dashboard')).toBeInTheDocument(); - }); - - 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 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/v1/health')).toBe(1); - - fireEvent.click(screen.getByRole('button', { name: 'Refresh health' })); - - expect(await screen.findByText('All checks passed')).toBeInTheDocument(); - expect(fetchCount('/api/v1/health')).toBe(2); - }); - - it('shows the API error detail when dashboard loading fails', async () => { - vi.spyOn(window, 'fetch').mockImplementation(() => - Promise.resolve(new Response( - JSON.stringify({ - detail: 'API write key is invalid', - status: 401, - title: 'Unauthorized' - }), - { - headers: { 'Content-Type': 'application/json' }, - status: 401 - } - )) - ); - - render(); - - expect(await screen.findByText('API write key is invalid')).toBeInTheDocument(); - }); - it('exports typed primitives with expected interactions', () => { const onSwitch = vi.fn(); const onCheckbox = vi.fn(); @@ -2879,10 +2683,6 @@ function mockDashboardApi({ }); } -function fetchCount(path: string): number { - return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length; -} - function fetchCallsStartingWith(prefix: string): string[] { return vi.mocked(window.fetch).mock.calls .map(([input]) => input.toString()) diff --git a/web/src/App.tsx b/web/src/App.tsx index 82bd72106..b15bc8e1c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,7 +34,6 @@ import { ListMusic, ListVideo, Plus, - Radio, RefreshCw, Repeat, ScrollText, @@ -93,6 +92,7 @@ import { DecosScreen } from './screens/DecosScreen'; import { DecoTemplatesScreen } from './screens/DecoTemplatesScreen'; import { TemplatesScreen } from './screens/TemplatesScreen'; import { PlayoutsRouteScreen } from './screens/PlayoutsScreen'; +import { DashboardHealthSummary, DashboardScreen } from './screens/DashboardScreen'; import { navigateToPath, parseLibrariesSubRoute, @@ -106,16 +106,13 @@ import { IconButton, NavItem, NavSection, - ProgressBar, Select, Spinner, - Stat, StatusDot } from './components'; import { useChannelsQuery, useDashboardHealthQuery, - useDashboardQuery, useDashboardVersionQuery, defaultGuideWindowStart, GUIDE_WINDOW_MS, @@ -123,8 +120,6 @@ import { type ChannelState, type ChannelGuideChannel, type ChannelGuideProgramme, - type DashboardChannel, - type DashboardChannelState, type DashboardHealthQueryState, } from './api'; import { @@ -679,8 +674,6 @@ function Sidebar({ healthState: DashboardHealthQueryState; onNavigate: (route: ScreenRoute, event: MouseEvent) => void; }) { - const healthSummary = summarizeHealth(healthState); - return ( ); @@ -897,275 +890,6 @@ function TopBar({ route }: { route: ScreenRoute | null }) { ); } -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' }; -} - -// 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 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