fix(web): dashboard review fixes — real health status contract, dedupe version fetch, refresh guard (#109)
- Match the backend's exact health status contract ('pass'|'fail'|'warn'|'info')
instead of fictional strings; 'info' now renders as a neutral/idle presentation
and is excluded from failing/warning counts in summarizeHealth.
- Drop the redundant /api/version fetch from getDashboardData/DashboardData;
SidebarVersion's useDashboardVersionQuery remains the single source.
- Disable "Refresh health" (via Button's loading prop) while a health request
is in flight to prevent concurrent double-click requests.
- Add the active-flag unmount guard to useDashboardHealthQuery for consistency
with useDashboardQuery/useChannelsQuery.
- Remove dead .ctv-activity-*/.ctv-release-* CSS left over from the removed
activity feed/release notes UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+35
-3
@@ -207,13 +207,13 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
{
|
||||
detail: 'SQLite is reachable',
|
||||
link: null,
|
||||
status: 'Healthy',
|
||||
status: 'pass',
|
||||
title: 'Database'
|
||||
},
|
||||
{
|
||||
detail: 'FFmpeg path is missing',
|
||||
link: null,
|
||||
status: 'Warning',
|
||||
status: 'warn',
|
||||
title: 'FFmpeg'
|
||||
}
|
||||
],
|
||||
@@ -290,13 +290,45 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
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: 'Healthy',
|
||||
status: 'pass',
|
||||
title: 'System'
|
||||
}
|
||||
]
|
||||
|
||||
+16
-3
@@ -495,14 +495,18 @@ function summarizeHealth(healthState: DashboardHealthQueryState): { label: strin
|
||||
return { label: 'Healthy', status: 'ok' };
|
||||
}
|
||||
|
||||
// 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() === 'warning' || status.toLowerCase() === 'degraded';
|
||||
return status.toLowerCase() === 'warn';
|
||||
}
|
||||
|
||||
function isErrorHealthStatus(status: string): boolean {
|
||||
const normalized = status.toLowerCase();
|
||||
return status.toLowerCase() === 'fail';
|
||||
}
|
||||
|
||||
return normalized === 'error' || normalized === 'failed' || normalized === 'unhealthy';
|
||||
function isInfoHealthStatus(status: string): boolean {
|
||||
return status.toLowerCase() === 'info';
|
||||
}
|
||||
|
||||
function healthIconStatus(status: string): HealthStatus {
|
||||
@@ -514,6 +518,10 @@ function healthIconStatus(status: string): HealthStatus {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
if (isInfoHealthStatus(status)) {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@@ -525,6 +533,10 @@ function healthIcon(status: string): ReactNode {
|
||||
}
|
||||
|
||||
if (iconStatus === 'warn') {
|
||||
return <TriangleAlert aria-hidden="true" size={15} />;
|
||||
}
|
||||
|
||||
if (iconStatus === 'idle') {
|
||||
return <Info aria-hidden="true" size={15} />;
|
||||
}
|
||||
|
||||
@@ -614,6 +626,7 @@ function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }
|
||||
actions={
|
||||
<Button
|
||||
onClick={healthState.refresh}
|
||||
loading={healthState.status === 'loading'}
|
||||
startIcon={<RefreshCw aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface DashboardData {
|
||||
channelStates: DashboardChannelState[];
|
||||
mediaSources: DashboardMediaSource[];
|
||||
playouts: DashboardPlayouts;
|
||||
version: DashboardVersion;
|
||||
}
|
||||
|
||||
export type DashboardQueryState =
|
||||
@@ -38,15 +37,14 @@ type DashboardHealthState =
|
||||
| { checks: null; error: null; status: 'loading' };
|
||||
|
||||
export async function getDashboardData(): Promise<DashboardData> {
|
||||
const [channels, channelStates, mediaSources, playouts, version] = await Promise.all([
|
||||
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
|
||||
request<DashboardChannel[]>('/api/channels'),
|
||||
request<DashboardChannelState[]>('/api/channels/state'),
|
||||
request<DashboardMediaSource[]>('/api/media-sources'),
|
||||
request<DashboardPlayouts>('/api/playouts'),
|
||||
request<DashboardVersion>('/api/version')
|
||||
request<DashboardPlayouts>('/api/playouts')
|
||||
]);
|
||||
|
||||
return { channels, channelStates, mediaSources, playouts, version };
|
||||
return { channels, channelStates, mediaSources, playouts };
|
||||
}
|
||||
|
||||
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
|
||||
@@ -94,10 +92,28 @@ export function useDashboardHealthQuery(): DashboardHealthQueryState {
|
||||
status: 'loading'
|
||||
});
|
||||
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadHealth = useCallback(() => {
|
||||
getDashboardHealth()
|
||||
.then((checks) => setState({ checks, error: null, status: 'success' }))
|
||||
.catch((error: unknown) => setState({ checks: null, error: messageFromError(error), status: 'error' }));
|
||||
.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(() => {
|
||||
|
||||
+2
-68
@@ -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;
|
||||
@@ -586,69 +583,6 @@
|
||||
padding: var(--space-4, 8px) var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.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);
|
||||
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;
|
||||
}
|
||||
|
||||
.ctv-live-channel-body {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user