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
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:
+7
-207
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user