- {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 +701,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 +784,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 +805,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 +838,12 @@ export function App() {
return (
diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts
index 756398c2a..e45c2bc67 100644
--- a/web/src/api/dashboard.ts
+++ b/web/src/api/dashboard.ts
@@ -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 {
- const [channels, collections, schedules, sessions, version] = await Promise.all([
+ const [channels, channelStates, mediaSources, playouts] = await Promise.all([
request('/api/channels'),
- request('/api/collections'),
- request('/api/schedules'),
- request('/api/sessions'),
- request('/api/version')
+ request('/api/channels/state'),
+ request('/api/media-sources'),
+ request('/api/playouts')
]);
- return { channels, collections, schedules, sessions, version };
+ return { channels, channelStates, mediaSources, playouts };
+}
+
+export function getDashboardHealth(): Promise {
+ return request('/api/health');
+}
+
+export function getDashboardVersion(): Promise {
+ return request('/api/version');
}
export function useDashboardQuery(): DashboardQueryState {
@@ -63,6 +85,87 @@ export function useDashboardQuery(): DashboardQueryState {
return state;
}
+export function useDashboardHealthQuery(): DashboardHealthQueryState {
+ const [state, setState] = useState({
+ 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({
+ 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..fcedceadd 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -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 {