refactor(web): extract Dashboard screen
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m19s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m57s

Move Dashboard rendering, health and on-air helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.

Refs #246

Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit was merged in pull request #360.
This commit is contained in:
2026-07-15 21:16:40 +02:00
co-authored by OpenAI Codex
parent ecde59be32
commit bceffef856
4 changed files with 592 additions and 485 deletions
+7 -207
View File
@@ -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(<App />);
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(<App />);
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<Response>(() => {}));
render(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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())
+2 -278
View File
@@ -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 (
<aside className="ctv-sidebar">
<div className="ctv-brand">
@@ -703,7 +696,7 @@ function Sidebar({
<span>ChicoryTV</span>
<SidebarVersion />
</div>
<StatusDot status={healthSummary.status} label={healthSummary.label} />
<DashboardHealthSummary healthState={healthState} />
</div>
</aside>
);
@@ -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 <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'warn') {
return <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'idle') {
return <Info aria-hidden="true" size={15} />;
}
return <Check aria-hidden="true" size={15} />;
}
function progressFromNowPlaying(nowPlaying: NonNullable<DashboardChannelState['nowPlaying']>): number | null {
const start = new Date(nowPlaying.startUtc).getTime();
const finish = new Date(nowPlaying.finishUtc).getTime();
const now = Date.now();
if (!Number.isFinite(start) || !Number.isFinite(finish) || finish <= start) {
return null;
}
return Math.round(((now - start) / (finish - start)) * 100);
}
function minutesUntil(value: string): number | null {
const finish = new Date(value).getTime();
if (!Number.isFinite(finish)) {
return null;
}
return Math.max(0, Math.ceil((finish - Date.now()) / 60000));
}
function OnAirCard({ channel, state }: { channel: DashboardChannel | null; state: DashboardChannelState }) {
const name = channel?.name ?? 'Unnamed channel';
const number = state.channelNumber || channel?.number || `${state.channelId}`;
const nowPlaying = state.nowPlaying;
const progress = nowPlaying ? progressFromNowPlaying(nowPlaying) : null;
const remaining = nowPlaying ? minutesUntil(nowPlaying.finishUtc) : null;
return (
<div className="ctv-onair-card">
<div className="ctv-onair-head">
<ChannelLogo name={name} size={34} />
<div>
<code>{number}</code>
<strong>{name}</strong>
</div>
<Badge tone="accent" dot>
On air
</Badge>
</div>
<p>{nowPlaying?.title ?? 'Now-playing data unavailable'}</p>
<ProgressBar value={progress} />
<div className="ctv-onair-meta">
<span>{progress == null ? 'Progress unavailable' : `${Math.max(0, Math.min(100, progress))}% elapsed`}</span>
<span>{remaining == null ? 'Finish unavailable' : `${remaining}m to next`}</span>
</div>
</div>
);
}
function DashboardLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading dashboard</span>
</div>
</Card>
);
}
function DashboardErrorState({ error }: { error: string }) {
return (
<Card title={<h2>Dashboard unavailable</h2>} subtitle="Live API request failed">
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{error}</strong>
</div>
</Card>
);
}
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
const summary = summarizeHealth(healthState);
return (
<Card
title={<h2>System health</h2>}
subtitle="Backend health checks"
actions={
<Button
onClick={healthState.refresh}
loading={healthState.status === 'loading'}
startIcon={<RefreshCw aria-hidden="true" size={14} />}
variant="secondary"
>
Refresh health
</Button>
}
padded={false}
>
<div className="ctv-health-panel">
{healthState.status === 'loading' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Spinner size={15} tone="muted" />
</span>
<strong>Health checks</strong>
<span>Loading current health</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'error' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-error">
<TriangleAlert aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>{healthState.error}</span>
<StatusDot status="error" />
</div>
)}
{healthState.status === 'success' && healthState.checks.length === 0 && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Info aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>No health checks returned</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'success' && healthState.checks.map((check) => {
const rowStatus = healthIconStatus(check.status);
return (
<div className="ctv-health-row" key={check.title}>
<span className={`ctv-health-icon ctv-health-icon-${rowStatus}`}>{healthIcon(check.status)}</span>
<strong>{check.title}</strong>
<span>{check.detail}</span>
<StatusDot status={rowStatus} />
</div>
);
})}
</div>
<div className="ctv-health-summary">
<StatusDot status={summary.status} label={summary.label} />
</div>
</Card>
);
}
function DashboardScreen({
healthState
}: {
healthState: DashboardHealthQueryState;
}) {
const dashboardQuery = useDashboardQuery();
if (dashboardQuery.status === 'loading') {
return <DashboardLoadingState />;
}
if (dashboardQuery.status === 'error') {
return <DashboardErrorState error={dashboardQuery.error} />;
}
const { channels, channelStates, mediaSources, playouts } = dashboardQuery.data;
const channelsById = new Map(channels.map((channel) => [channel.id, channel]));
const onAirStates = channelStates.filter((state) => state.onAir).slice(0, 4);
const playoutCount = playouts.totalCount;
const libraryCount = mediaSources.reduce((count, source) => count + source.libraries.length, 0);
return (
<div className="ctv-screen-stack">
<section className="ctv-stat-row" aria-label="At a glance">
<Stat label="Channels" value={channels.length} icon={<Tv aria-hidden="true" size={15} />} />
<Stat label="Active playouts" value={playoutCount} icon={<ListVideo aria-hidden="true" size={15} />} />
<Stat label="On air" value={onAirStates.length} icon={<Radio aria-hidden="true" size={15} />} />
<Stat label="Libraries" value={libraryCount} icon={<Library aria-hidden="true" size={15} />} />
</section>
<section className="ctv-dashboard-grid">
<Card
title={<h2>On air now</h2>}
subtitle="Current programmes by channel"
actions={<Badge tone="accent" dot>{onAirStates.length} on air</Badge>}
>
<div className="ctv-onair-grid">
{onAirStates.length > 0 ? (
onAirStates.map((state) => (
<OnAirCard channel={channelsById.get(state.channelId) ?? null} state={state} key={state.channelId} />
))
) : (
<div className="ctv-dashboard-empty">No on-air channels reported</div>
)}
</div>
</Card>
<HealthPanel healthState={healthState} />
</section>
</div>
);
}
const EPG_SLOT_MINUTES = 30;
const EPG_SLOT_WIDTH = 156;
const EPG_RAIL_WIDTH = 196;
+282
View File
@@ -0,0 +1,282 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDashboardHealthQuery } from '../api';
import { DashboardScreen } from './DashboardScreen';
describe('DashboardScreen', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
beforeEach(() => {
mockDashboardApi();
});
it('renders dashboard cards without the retired placeholder sections', async () => {
renderDashboard();
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
}
});
renderDashboard();
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.getByText('1 warning')).toBeInTheDocument();
expect(screen.getAllByText('2')).toHaveLength(2);
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).not.toHaveBeenCalledWith('/api/v1/sessions', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/schedules', expect.any(Object));
});
it('shows the dashboard loading state while requests are pending', async () => {
vi.mocked(window.fetch).mockImplementation(() => new Promise<Response>(() => {}));
renderDashboard();
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 }]
});
renderDashboard();
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 } = renderDashboard();
expect(await screen.findByText('FFmpeg path is missing')).toBeInTheDocument();
expect(screen.getByText('1 failing')).toBeInTheDocument();
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'
}
]
});
renderDashboard();
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.mocked(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
}
))
);
renderDashboard();
expect(await screen.findByText('API write key is invalid')).toBeInTheDocument();
});
});
function DashboardUnderTest() {
const healthState = useDashboardHealthQuery();
return <DashboardScreen healthState={healthState} />;
}
function renderDashboard() {
return render(<DashboardUnderTest />);
}
function mockDashboardApi({
channels = [],
channelStates = [],
health = [],
mediaSources = [],
playouts = { page: [], totalCount: 0 }
}: {
channels?: unknown[];
channelStates?: unknown[];
health?: unknown[];
mediaSources?: unknown[];
playouts?: unknown;
} = {}) {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
if (path === '/api/v1/channels') {
return Promise.resolve(jsonResponse(channels));
}
if (path === '/api/v1/channels/state') {
return Promise.resolve(jsonResponse(channelStates));
}
if (path === '/api/v1/media-sources') {
return Promise.resolve(jsonResponse(mediaSources));
}
if (path === '/api/v1/playouts') {
return Promise.resolve(jsonResponse(playouts));
}
if (path === '/api/v1/health') {
return Promise.resolve(jsonResponse(health));
}
return Promise.resolve(jsonResponse(null, 404));
});
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function fetchCount(path: string): number {
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
}
+301
View File
@@ -0,0 +1,301 @@
import type { ReactNode } from 'react';
import {
Check,
Info,
Library,
ListVideo,
Radio,
RefreshCw,
TriangleAlert,
Tv
} from 'lucide-react';
import {
Badge,
Button,
Card,
ChannelLogo,
ProgressBar,
Spinner,
Stat,
StatusDot
} from '../components';
import {
useDashboardQuery,
type DashboardChannel,
type DashboardChannelState,
type DashboardHealthQueryState
} from '../api';
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' };
}
export function DashboardHealthSummary({ healthState }: { healthState: DashboardHealthQueryState }) {
const summary = summarizeHealth(healthState);
return <StatusDot status={summary.status} label={summary.label} />;
}
// 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 <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'warn') {
return <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'idle') {
return <Info aria-hidden="true" size={15} />;
}
return <Check aria-hidden="true" size={15} />;
}
function progressFromNowPlaying(nowPlaying: NonNullable<DashboardChannelState['nowPlaying']>): number | null {
const start = new Date(nowPlaying.startUtc).getTime();
const finish = new Date(nowPlaying.finishUtc).getTime();
const now = Date.now();
if (!Number.isFinite(start) || !Number.isFinite(finish) || finish <= start) {
return null;
}
return Math.round(((now - start) / (finish - start)) * 100);
}
function minutesUntil(value: string): number | null {
const finish = new Date(value).getTime();
if (!Number.isFinite(finish)) {
return null;
}
return Math.max(0, Math.ceil((finish - Date.now()) / 60000));
}
function OnAirCard({ channel, state }: { channel: DashboardChannel | null; state: DashboardChannelState }) {
const name = channel?.name ?? 'Unnamed channel';
const number = state.channelNumber || channel?.number || `${state.channelId}`;
const nowPlaying = state.nowPlaying;
const progress = nowPlaying ? progressFromNowPlaying(nowPlaying) : null;
const remaining = nowPlaying ? minutesUntil(nowPlaying.finishUtc) : null;
return (
<div className="ctv-onair-card">
<div className="ctv-onair-head">
<ChannelLogo name={name} size={34} />
<div>
<code>{number}</code>
<strong>{name}</strong>
</div>
<Badge tone="accent" dot>
On air
</Badge>
</div>
<p>{nowPlaying?.title ?? 'Now-playing data unavailable'}</p>
<ProgressBar value={progress} />
<div className="ctv-onair-meta">
<span>{progress == null ? 'Progress unavailable' : `${Math.max(0, Math.min(100, progress))}% elapsed`}</span>
<span>{remaining == null ? 'Finish unavailable' : `${remaining}m to next`}</span>
</div>
</div>
);
}
function DashboardLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading dashboard</span>
</div>
</Card>
);
}
function DashboardErrorState({ error }: { error: string }) {
return (
<Card title={<h2>Dashboard unavailable</h2>} subtitle="Live API request failed">
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{error}</strong>
</div>
</Card>
);
}
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
return (
<Card
title={<h2>System health</h2>}
subtitle="Backend health checks"
actions={
<Button
onClick={healthState.refresh}
loading={healthState.status === 'loading'}
startIcon={<RefreshCw aria-hidden="true" size={14} />}
variant="secondary"
>
Refresh health
</Button>
}
padded={false}
>
<div className="ctv-health-panel">
{healthState.status === 'loading' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Spinner size={15} tone="muted" />
</span>
<strong>Health checks</strong>
<span>Loading current health</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'error' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-error">
<TriangleAlert aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>{healthState.error}</span>
<StatusDot status="error" />
</div>
)}
{healthState.status === 'success' && healthState.checks.length === 0 && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Info aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>No health checks returned</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'success' && healthState.checks.map((check) => {
const rowStatus = healthIconStatus(check.status);
return (
<div className="ctv-health-row" key={check.title}>
<span className={`ctv-health-icon ctv-health-icon-${rowStatus}`}>{healthIcon(check.status)}</span>
<strong>{check.title}</strong>
<span>{check.detail}</span>
<StatusDot status={rowStatus} />
</div>
);
})}
</div>
<div className="ctv-health-summary">
<DashboardHealthSummary healthState={healthState} />
</div>
</Card>
);
}
export function DashboardScreen({
healthState
}: {
healthState: DashboardHealthQueryState;
}) {
const dashboardQuery = useDashboardQuery();
if (dashboardQuery.status === 'loading') {
return <DashboardLoadingState />;
}
if (dashboardQuery.status === 'error') {
return <DashboardErrorState error={dashboardQuery.error} />;
}
const { channels, channelStates, mediaSources, playouts } = dashboardQuery.data;
const channelsById = new Map(channels.map((channel) => [channel.id, channel]));
const onAirStates = channelStates.filter((state) => state.onAir).slice(0, 4);
const playoutCount = playouts.totalCount;
const libraryCount = mediaSources.reduce((count, source) => count + source.libraries.length, 0);
return (
<div className="ctv-screen-stack">
<section className="ctv-stat-row" aria-label="At a glance">
<Stat label="Channels" value={channels.length} icon={<Tv aria-hidden="true" size={15} />} />
<Stat label="Active playouts" value={playoutCount} icon={<ListVideo aria-hidden="true" size={15} />} />
<Stat label="On air" value={onAirStates.length} icon={<Radio aria-hidden="true" size={15} />} />
<Stat label="Libraries" value={libraryCount} icon={<Library aria-hidden="true" size={15} />} />
</section>
<section className="ctv-dashboard-grid">
<Card
title={<h2>On air now</h2>}
subtitle="Current programmes by channel"
actions={<Badge tone="accent" dot>{onAirStates.length} on air</Badge>}
>
<div className="ctv-onair-grid">
{onAirStates.length > 0 ? (
onAirStates.map((state) => (
<OnAirCard channel={channelsById.get(state.channelId) ?? null} state={state} key={state.channelId} />
))
) : (
<div className="ctv-dashboard-empty">No on-air channels reported</div>
)}
</div>
</Card>
<HealthPanel healthState={healthState} />
</section>
</div>
);
}