Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m31s

This commit was merged in pull request #123.
This commit is contained in:
2026-07-04 19:20:38 +00:00
4 changed files with 509 additions and 259 deletions
+159 -27
View File
@@ -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(<App />);
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(<App />);
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(<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/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;
}
+217 -151
View File
@@ -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 (
<aside className="ctv-sidebar">
<div className="ctv-brand">
@@ -291,14 +302,28 @@ function Sidebar({
<div className="ctv-sidebar-health">
<div>
<span>ChicoryTV</span>
<code>v26.4.0</code>
<SidebarVersion />
</div>
<StatusDot status="ok" label="Healthy" />
<StatusDot status={healthSummary.status} label={healthSummary.label} />
</div>
</aside>
);
}
function SidebarVersion() {
const versionQuery = useDashboardVersionQuery();
if (versionQuery.status === 'success') {
return <code>{versionQuery.version.appVersion ?? 'version unknown'}</code>;
}
if (versionQuery.status === 'error') {
return <code>version unavailable</code>;
}
return <code>loading version</code>;
}
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 <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">
@@ -472,11 +584,11 @@ function OnAirCard({ channel, index }: { channel: DashboardChannel; index: numbe
On air
</Badge>
</div>
<p>{programForChannel(channel)}</p>
<p>{nowPlaying?.title ?? 'Now-playing data unavailable'}</p>
<ProgressBar value={progress} />
<div className="ctv-onair-meta">
<span>{progress}% elapsed</span>
<span>{Math.max(4, 60 - progress)}m to next</span>
<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>
);
@@ -504,128 +616,81 @@ function DashboardErrorState({ error }: { error: string }) {
);
}
function HealthPanel({
channelCount,
sessionCount,
apiVersion
}: {
channelCount: number;
sessionCount: number;
apiVersion: number;
}) {
const rows = [
{
detail: `API v${apiVersion} responding`,
icon: <Check aria-hidden="true" size={15} />,
label: 'REST API',
status: 'ok' as const
},
{
detail: channelCount > 0 ? `${channelCount} channels loaded` : 'No channels returned',
icon: channelCount > 0 ? <Check aria-hidden="true" size={15} /> : <Info aria-hidden="true" size={15} />,
label: 'Channel catalog',
status: channelCount > 0 ? ('ok' as const) : ('warn' as const)
},
{
detail: sessionCount > 0 ? `${sessionCount} active HLS sessions` : 'No active HLS sessions',
icon: sessionCount > 0 ? <Zap aria-hidden="true" size={15} /> : <Info aria-hidden="true" size={15} />,
label: 'Streaming sessions',
status: sessionCount > 0 ? ('live' as const) : ('idle' as const)
}
];
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
const summary = summarizeHealth(healthState);
return (
<Card
title={<h2>System health</h2>}
subtitle="Condensed API status"
actions={<Badge tone={rows.some((row) => row.status === 'warn') ? 'warn' : 'ok'}>Live</Badge>}
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">
{rows.map((row) => (
<div className="ctv-health-row" key={row.label}>
<span className={`ctv-health-icon ctv-health-icon-${row.status}`}>{row.icon}</span>
<strong>{row.label}</strong>
<span>{row.detail}</span>
<StatusDot status={row.status} />
{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 RecentActivity({
channelCount,
collectionCount,
sessionCount
function DashboardScreen({
healthState
}: {
channelCount: number;
collectionCount: number;
sessionCount: number;
healthState: DashboardHealthQueryState;
}) {
const items = [
{
icon: <Tv aria-hidden="true" size={13} />,
label: 'Channels',
text: `Loaded ${channelCount} channels from /api/channels`,
time: 'now',
tone: 'accent' as const
},
{
icon: <Radio aria-hidden="true" size={13} />,
label: 'Sessions',
text: `${sessionCount} active streaming sessions reported`,
time: 'live',
tone: sessionCount > 0 ? ('accent' as const) : ('neutral' as const)
},
{
icon: <FolderTree aria-hidden="true" size={13} />,
label: 'Media',
text: `${collectionCount} collections available to schedules`,
time: 'api',
tone: 'neutral' as const
}
];
return (
<Card title={<h2>Recent activity</h2>} subtitle="API-backed dashboard events" padded={false}>
<div className="ctv-activity-feed">
{items.map((item) => (
<div className="ctv-activity-row" key={item.label}>
<span className="ctv-activity-icon">{item.icon}</span>
<Tag tone={item.tone}>{item.label}</Tag>
<span>{item.text}</span>
<code>{item.time}</code>
</div>
))}
</div>
</Card>
);
}
function ReleaseNotes({ appVersion, apiVersion }: { appVersion: string | null; apiVersion: number }) {
const [open, setOpen] = useState(false);
return (
<Card>
<button type="button" className="ctv-release-toggle" onClick={() => setOpen((current) => !current)}>
<Sparkles aria-hidden="true" size={15} />
<strong>Release notes</strong>
<code>{appVersion ?? 'unknown'}</code>
<ChevronDown aria-hidden="true" size={14} />
</button>
{open && (
<div className="ctv-release-body">
<p>Running ChicoryTV app version {appVersion ?? 'unknown'} against REST API v{apiVersion}.</p>
<p>Release-note content will use the server release feed when it is exposed through the REST API.</p>
</div>
)}
</Card>
);
}
function DashboardScreen() {
const dashboardQuery = useDashboardQuery();
if (dashboardQuery.status === 'loading') {
@@ -636,45 +701,39 @@ function DashboardScreen() {
return <DashboardErrorState error={dashboardQuery.error} />;
}
const { channels, collections, schedules, sessions, version } = dashboardQuery.data;
const onAirChannels = channels.slice(0, 4);
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={schedules.length} icon={<ListVideo aria-hidden="true" size={15} />} />
<Stat label="Transcodes" value={sessions.length} icon={<Radio aria-hidden="true" size={15} />} delta={sessions.length > 0 ? 'streaming' : 'idle'} deltaTone="neutral" />
<Stat label="Libraries" value={collections.length} icon={<Library 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>{sessions.length} streaming</Badge>}
actions={<Badge tone="accent" dot>{onAirStates.length} on air</Badge>}
>
<div className="ctv-onair-grid">
{onAirChannels.length > 0 ? (
onAirChannels.map((channel, index) => (
<OnAirCard channel={channel} index={index} key={channel.id} />
{onAirStates.length > 0 ? (
onAirStates.map((state) => (
<OnAirCard channel={channelsById.get(state.channelId) ?? null} state={state} key={state.channelId} />
))
) : (
<div className="ctv-dashboard-empty">No channels found</div>
<div className="ctv-dashboard-empty">No on-air channels reported</div>
)}
</div>
</Card>
<HealthPanel channelCount={channels.length} sessionCount={sessions.length} apiVersion={version.apiVersion} />
</section>
<section className="ctv-dashboard-grid">
<RecentActivity
channelCount={channels.length}
collectionCount={collections.length}
sessionCount={sessions.length}
/>
<ReleaseNotes appVersion={version.appVersion} apiVersion={version.apiVersion} />
<HealthPanel healthState={healthState} />
</section>
</div>
);
@@ -725,13 +784,19 @@ function NotFoundScreen() {
);
}
function ScreenContent({ route }: { route: ScreenRoute | null }) {
function ScreenContent({
healthState,
route
}: {
healthState: DashboardHealthQueryState;
route: ScreenRoute | null;
}) {
if (route === null) {
return <NotFoundScreen />;
}
if (route.id === 'dashboard') {
return <DashboardScreen />;
return <DashboardScreen healthState={healthState} />;
}
return <PlaceholderScreen route={route} />;
@@ -740,6 +805,7 @@ function ScreenContent({ route }: { route: ScreenRoute | null }) {
export function App() {
const [theme, setTheme] = useState<DesignSystemThemeId>(() => getStoredDesignSystemTheme());
const [activeRoute, setActiveRoute] = useState<ScreenRoute | null>(() => routeFromLocation());
const healthState = useDashboardHealthQuery();
useEffect(() => {
applyDesignSystemTheme(theme);
@@ -772,12 +838,12 @@ export function App() {
return (
<div className="ctv-app-shell">
<Sidebar activeRoute={route} onNavigate={navigate} />
<Sidebar activeRoute={route} healthState={healthState} onNavigate={navigate} />
<div className="ctv-shell-body">
<TopBar route={route} />
<main className="ctv-main">
<ThemeSwitcher theme={theme} onThemeChange={setTheme} />
<ScreenContent route={route} />
<ScreenContent healthState={healthState} route={route} />
</main>
</div>
</div>
+119 -16
View File
@@ -1,36 +1,58 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type DashboardChannel = components['schemas']['ChannelResponseModel'];
type DashboardCollection = components['schemas']['MediaCollectionViewModel'];
type DashboardSchedule = components['schemas']['ProgramScheduleViewModel'];
type DashboardSession = components['schemas']['HlsSessionModel'];
type DashboardVersion = components['schemas']['CombinedVersion'];
export type DashboardChannelState = components['schemas']['ChannelStateResponseModel'];
type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
type DashboardMediaSource = components['schemas']['MediaSourceResponseModel'];
type DashboardPlayouts = components['schemas']['PagedPlayoutsResponseModel'];
export type DashboardVersion = components['schemas']['CombinedVersion'];
export interface DashboardData {
channels: DashboardChannel[];
collections: DashboardCollection[];
schedules: DashboardSchedule[];
sessions: DashboardSession[];
version: DashboardVersion;
channelStates: DashboardChannelState[];
mediaSources: DashboardMediaSource[];
playouts: DashboardPlayouts;
}
type DashboardQueryState =
export type DashboardQueryState =
| { data: DashboardData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export type DashboardHealthQueryState =
| { checks: DashboardHealthCheck[]; error: null; refresh: () => void; status: 'success' }
| { checks: null; error: string; refresh: () => void; status: 'error' }
| { checks: null; error: null; refresh: () => void; status: 'loading' };
export type DashboardVersionQueryState =
| { error: null; status: 'success'; version: DashboardVersion }
| { error: string; status: 'error'; version: null }
| { error: null; status: 'loading'; version: null };
type DashboardHealthState =
| { checks: DashboardHealthCheck[]; error: null; status: 'success' }
| { checks: null; error: string; status: 'error' }
| { checks: null; error: null; status: 'loading' };
export async function getDashboardData(): Promise<DashboardData> {
const [channels, collections, schedules, sessions, version] = await Promise.all([
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
request<DashboardChannel[]>('/api/channels'),
request<DashboardCollection[]>('/api/collections'),
request<DashboardSchedule[]>('/api/schedules'),
request<DashboardSession[]>('/api/sessions'),
request<DashboardVersion>('/api/version')
request<DashboardChannelState[]>('/api/channels/state'),
request<DashboardMediaSource[]>('/api/media-sources'),
request<DashboardPlayouts>('/api/playouts')
]);
return { channels, collections, schedules, sessions, version };
return { channels, channelStates, mediaSources, playouts };
}
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
return request<DashboardHealthCheck[]>('/api/health');
}
export function getDashboardVersion(): Promise<DashboardVersion> {
return request<DashboardVersion>('/api/version');
}
export function useDashboardQuery(): DashboardQueryState {
@@ -63,6 +85,87 @@ export function useDashboardQuery(): DashboardQueryState {
return state;
}
export function useDashboardHealthQuery(): DashboardHealthQueryState {
const [state, setState] = useState<DashboardHealthState>({
checks: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadHealth = useCallback(() => {
getDashboardHealth()
.then((checks) => {
if (activeRef.current) {
setState({ checks, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ checks: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const refresh = useCallback(() => {
setState({ checks: null, error: null, status: 'loading' });
loadHealth();
}, [loadHealth]);
useEffect(() => {
loadHealth();
}, [loadHealth]);
if (state.status === 'success') {
return { checks: state.checks, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { checks: null, error: state.error, refresh, status: 'error' };
}
return { checks: null, error: null, refresh, status: 'loading' };
}
export function useDashboardVersionQuery(): DashboardVersionQueryState {
const [state, setState] = useState<DashboardVersionQueryState>({
error: null,
status: 'loading',
version: null
});
useEffect(() => {
let active = true;
getDashboardVersion()
.then((version) => {
if (active) {
setState({ error: null, status: 'success', version });
}
})
.catch((error: unknown) => {
if (active) {
setState({ error: messageFromError(error), status: 'error', version: null });
}
});
return () => {
active = false;
};
}, []);
return state;
}
function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+14 -65
View File
@@ -442,7 +442,6 @@
}
.ctv-onair-head code,
.ctv-activity-list code,
.ctv-live-channel-row code {
color: var(--text-secondary);
font-family: var(--font-mono, ui-monospace, monospace);
@@ -516,8 +515,7 @@
gap: var(--space-7, 16px);
}
.ctv-health-panel,
.ctv-activity-feed {
.ctv-health-panel {
display: grid;
}
@@ -535,8 +533,7 @@
border-top: 0;
}
.ctv-health-icon,
.ctv-activity-icon {
.ctv-health-icon {
width: 24px;
height: 24px;
display: inline-flex;
@@ -555,6 +552,14 @@
color: var(--status-warn);
}
.ctv-health-icon-error {
color: var(--status-error);
}
.ctv-health-icon-idle {
color: var(--text-secondary);
}
.ctv-health-icon-live {
color: var(--status-live);
}
@@ -571,67 +576,11 @@
white-space: nowrap;
}
.ctv-activity-row {
min-height: 44px;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4, 8px);
.ctv-health-summary {
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-hairline);
padding: 0 var(--space-6, 12px);
}
.ctv-activity-row:first-child {
border-top: 0;
}
.ctv-activity-row > span:nth-child(3) {
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-activity-row code,
.ctv-release-toggle code {
color: var(--text-disabled);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-2xs, 11px);
}
.ctv-release-toggle {
width: 100%;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4, 8px);
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
padding: 0;
text-align: left;
}
.ctv-release-toggle svg:first-child {
color: var(--action-primary);
}
.ctv-release-toggle code {
justify-self: end;
}
.ctv-release-body {
display: grid;
gap: var(--space-3, 6px);
margin-top: var(--space-6, 12px);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
line-height: 1.45;
}
.ctv-release-body p {
margin: 0;
padding: var(--space-4, 8px) var(--space-6, 12px);
}
.ctv-live-channel-body {