Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
This commit was merged in pull request #123.
This commit is contained in:
+159
-27
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user