- {rows.map((row) => (
-
-
{row.icon}
-
{row.label}
-
{row.detail}
-
+ {healthState.status === 'loading' && (
+
+
+
+
+ Health checks
+ Loading current health
+
- ))}
+ )}
+ {healthState.status === 'error' && (
+
+
+
+
+ Health checks
+ {healthState.error}
+
+
+ )}
+ {healthState.status === 'success' && healthState.checks.length === 0 && (
+
+
+
+
+ Health checks
+ No health checks returned
+
+
+ )}
+ {healthState.status === 'success' && healthState.checks.map((check) => {
+ const rowStatus = healthIconStatus(check.status);
+
+ return (
+
+ {healthIcon(check.status)}
+ {check.title}
+ {check.detail}
+
+
+ );
+ })}
+
+
+
);
}
-function RecentActivity({
- channelCount,
- collectionCount,
- sessionCount
+function DashboardScreen({
+ healthState
}: {
- channelCount: number;
- collectionCount: number;
- sessionCount: number;
+ healthState: DashboardHealthQueryState;
}) {
- const items = [
- {
- icon:
,
- label: 'Channels',
- text: `Loaded ${channelCount} channels from /api/channels`,
- time: 'now',
- tone: 'accent' as const
- },
- {
- icon:
,
- label: 'Sessions',
- text: `${sessionCount} active streaming sessions reported`,
- time: 'live',
- tone: sessionCount > 0 ? ('accent' as const) : ('neutral' as const)
- },
- {
- icon:
,
- label: 'Media',
- text: `${collectionCount} collections available to schedules`,
- time: 'api',
- tone: 'neutral' as const
- }
- ];
-
- return (
-
Recent activity} subtitle="API-backed dashboard events" padded={false}>
-
- {items.map((item) => (
-
- {item.icon}
- {item.label}
- {item.text}
- {item.time}
-
- ))}
-
-
- );
-}
-
-function ReleaseNotes({ appVersion, apiVersion }: { appVersion: string | null; apiVersion: number }) {
- const [open, setOpen] = useState(false);
-
- return (
-
-
- {open && (
-
-
Running ChicoryTV app version {appVersion ?? 'unknown'} against REST API v{apiVersion}.
-
Release-note content will use the server release feed when it is exposed through the REST API.
-
- )}
-
- );
-}
-
-function DashboardScreen() {
const dashboardQuery = useDashboardQuery();
if (dashboardQuery.status === 'loading') {
@@ -636,45 +689,39 @@ function DashboardScreen() {
return
;
}
- 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 (
} />
- } />
- } delta={sessions.length > 0 ? 'streaming' : 'idle'} deltaTone="neutral" />
- } />
+ } />
+ } />
+ } />
On air now}
subtitle="Current programmes by channel"
- actions={{sessions.length} streaming}
+ actions={{onAirStates.length} on air}
>
- {onAirChannels.length > 0 ? (
- onAirChannels.map((channel, index) => (
-
+ {onAirStates.length > 0 ? (
+ onAirStates.map((state) => (
+
))
) : (
-
No channels found
+
No on-air channels reported
)}
-
-
-
-
);
@@ -725,13 +772,19 @@ function NotFoundScreen() {
);
}
-function ScreenContent({ route }: { route: ScreenRoute | null }) {
+function ScreenContent({
+ healthState,
+ route
+}: {
+ healthState: DashboardHealthQueryState;
+ route: ScreenRoute | null;
+}) {
if (route === null) {
return
;
}
if (route.id === 'dashboard') {
- return
;
+ return
;
}
return
;
@@ -740,6 +793,7 @@ function ScreenContent({ route }: { route: ScreenRoute | null }) {
export function App() {
const [theme, setTheme] = useState
(() => getStoredDesignSystemTheme());
const [activeRoute, setActiveRoute] = useState(() => routeFromLocation());
+ const healthState = useDashboardHealthQuery();
useEffect(() => {
applyDesignSystemTheme(theme);
@@ -772,12 +826,12 @@ export function App() {
return (
diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts
index 756398c2a..539085218 100644
--- a/web/src/api/dashboard.ts
+++ b/web/src/api/dashboard.ts
@@ -1,36 +1,60 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, 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[];
+ channelStates: DashboardChannelState[];
+ mediaSources: DashboardMediaSource[];
+ playouts: DashboardPlayouts;
version: DashboardVersion;
}
-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 {
- const [channels, collections, schedules, sessions, version] = await Promise.all([
+ const [channels, channelStates, mediaSources, playouts, version] = await Promise.all([
request('/api/channels'),
- request('/api/collections'),
- request('/api/schedules'),
- request('/api/sessions'),
+ request('/api/channels/state'),
+ request('/api/media-sources'),
+ request('/api/playouts'),
request('/api/version')
]);
- return { channels, collections, schedules, sessions, version };
+ return { channels, channelStates, mediaSources, playouts, version };
+}
+
+export function getDashboardHealth(): Promise {
+ return request('/api/health');
+}
+
+export function getDashboardVersion(): Promise {
+ return request('/api/version');
}
export function useDashboardQuery(): DashboardQueryState {
@@ -63,6 +87,66 @@ export function useDashboardQuery(): DashboardQueryState {
return state;
}
+export function useDashboardHealthQuery(): DashboardHealthQueryState {
+ const [state, setState] = useState({
+ checks: null,
+ error: null,
+ status: 'loading'
+ });
+
+ const refresh = useCallback(() => {
+ setState({ checks: null, error: null, status: 'loading' });
+
+ getDashboardHealth()
+ .then((checks) => setState({ checks, error: null, status: 'success' }))
+ .catch((error: unknown) => setState({ checks: null, error: messageFromError(error), status: 'error' }));
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ 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({
+ 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;
diff --git a/web/src/shell.css b/web/src/shell.css
index b8df3018d..3b9ca9f9b 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -555,6 +555,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,6 +579,13 @@
white-space: nowrap;
}
+.ctv-health-summary {
+ display: flex;
+ justify-content: flex-end;
+ border-top: 1px solid var(--border-hairline);
+ padding: var(--space-4, 8px) var(--space-6, 12px);
+}
+
.ctv-activity-row {
min-height: 44px;
display: grid;