feat(web): add Playouts monitor screen refs #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

This commit is contained in:
2026-07-05 17:10:30 +02:00
parent c5021e28da
commit 1840ef83b1
5 changed files with 1248 additions and 2 deletions
+1
View File
@@ -2,5 +2,6 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];
items: PlayoutItem[];
playout: PlayoutDetail | null;
playouts: PlayoutSummary[];
selectedPlayoutId: number | null;
totalCount: number;
warningsCount: number;
}
export type PlayoutsScreenQueryState =
| {
data: PlayoutsScreenData;
error: null;
refresh: () => void;
setActivePlayout: (playoutId: number) => void;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type PlayoutsScreenState =
| { data: PlayoutsScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getPlayouts(): Promise<PlayoutsPage> {
return request<PlayoutsPage>('/api/playouts');
}
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items`);
}
export function getPlayoutWarningsCount(): Promise<number> {
return request<number>('/api/playouts/warnings/count');
}
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
}
export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
}
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
const [state, setState] = useState<PlayoutsScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const selectedPlayoutIdRef = useRef<number | null>(null);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSelectedPlayout = useCallback((playoutId: number, base: Omit<PlayoutsScreenData, 'items' | 'playout'>) => {
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId)])
.then(([playout, itemsPage]) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState({
data: {
...base,
items: itemsPage.page ?? [],
playout,
selectedPlayoutId: playoutId
},
error: null,
status: 'success'
});
})
.catch((error: unknown) => {
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, []);
const load = useCallback((showLoading = true) => {
if (showLoading) {
setState({ data: null, error: null, status: 'loading' });
}
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
? selectedPlayoutIdRef.current
: playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, [loadSelectedPlayout]);
const loadChannelStates = useCallback(() => {
getPlayoutChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
})
.catch(() => {
// Keep the monitor visible on background polling failures.
});
}, []);
useEffect(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
const intervalId = window.setInterval(loadChannelStates, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [loadChannelStates, loadSelectedPlayout, pollMs]);
const refresh = useCallback(() => {
load();
}, [load]);
const setActivePlayout = useCallback((playoutId: number) => {
selectedPlayoutIdRef.current = playoutId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: [],
playout: null,
selectedPlayoutId: playoutId
},
error: null,
status: 'success'
};
});
setState((current) => {
if (current.status !== 'success') {
return current;
}
const base = {
channelStates: current.data.channelStates,
playouts: current.data.playouts,
selectedPlayoutId: playoutId,
totalCount: current.data.totalCount,
warningsCount: current.data.warningsCount
};
loadSelectedPlayout(playoutId, base);
return current;
});
}, [loadSelectedPlayout]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, setActivePlayout, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}