From 4e2baf1fa14c416520b6c4adad8975a056353e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 18:28:15 +0000 Subject: [PATCH] feat(network): add session status card Display server health metrics from the unused /session endpoint: - Connected clients count (live from useClientStore) - Server uptime since last restart - Last client connection time (relative) - Last integration request time (relative) - Server timezone - Cloud latency (Docker only) with warning threshold Adds useSessionStats hook with 60s refetch interval, new NetworkStatus component as first card in Network panel, and navigation menu entries for Server status and Network interfaces sections. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx --- apps/client/src/common/api/constants.ts | 1 + apps/client/src/common/api/session.ts | 10 +- .../src/common/hooks-query/useSessionStats.ts | 17 +++ .../panel/network-panel/NetworkLogPanel.tsx | 54 +-------- .../panel/network-panel/NetworkStatus.tsx | 109 ++++++++++++++++++ .../app-settings/useAppSettingsMenu.tsx | 4 + 6 files changed, 146 insertions(+), 49 deletions(-) create mode 100644 apps/client/src/common/hooks-query/useSessionStats.ts create mode 100644 apps/client/src/features/app-settings/panel/network-panel/NetworkStatus.tsx diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index aa0551931..eb9547cc7 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -15,6 +15,7 @@ export const RUNDOWN = ['rundown']; export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current']; export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId]; export const RUNTIME = ['runtimeStore']; +export const SESSION_STATS = ['sessionStats']; export const URL_PRESETS = ['urlpresets']; export const VIEW_SETTINGS = ['viewSettings']; export const CSS_OVERRIDE = ['cssOverride']; diff --git a/apps/client/src/common/api/session.ts b/apps/client/src/common/api/session.ts index 1fe55ccef..fde8845f5 100644 --- a/apps/client/src/common/api/session.ts +++ b/apps/client/src/common/api/session.ts @@ -1,11 +1,19 @@ import axios from 'axios'; -import { GetInfo, LinkOptions } from 'ontime-types'; +import { GetInfo, LinkOptions, SessionStats } from 'ontime-types'; import { apiEntryUrl } from './constants'; import type { RequestOptions } from './requestOptions'; const sessionPath = `${apiEntryUrl}/session`; +/** + * HTTP request to retrieve statistics of the running session + */ +export async function getSessionStats(options?: RequestOptions): Promise { + const res = await axios.get(sessionPath, { signal: options?.signal }); + return res.data; +} + /** * HTTP request to retrieve application info */ diff --git a/apps/client/src/common/hooks-query/useSessionStats.ts b/apps/client/src/common/hooks-query/useSessionStats.ts new file mode 100644 index 000000000..6dc9bc43c --- /dev/null +++ b/apps/client/src/common/hooks-query/useSessionStats.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { SessionStats } from 'ontime-types'; + +import { queryRefetchInterval } from '../../ontimeConfig'; +import { SESSION_STATS } from '../api/constants'; +import { getSessionStats } from '../api/session'; + +export default function useSessionStats() { + const { data, status, isError, refetch } = useQuery({ + queryKey: SESSION_STATS, + queryFn: ({ signal }) => getSessionStats({ signal }), + placeholderData: (previousData, _previousQuery) => previousData, + refetchInterval: queryRefetchInterval, + }); + + return { data, status, isError, refetch }; +} diff --git a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx index 3f8cab908..a80523680 100644 --- a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx +++ b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx @@ -1,21 +1,14 @@ -import { MessageTag } from 'ontime-types'; -import { useEffect } from 'react'; - -import Tag from '../../../../common/components/tag/Tag'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; -import { usePing } from '../../../../common/hooks/useSocket'; -import { sendSocket } from '../../../../common/utils/socket'; -import { isDocker, isOntimeCloud } from '../../../../externals'; +import { isOntimeCloud } from '../../../../externals'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import ClientControlPanel from './client-control/ClientControlPanel'; import InfoNif from './NetworkInterfaces'; import LogExport from './NetworkLogExport'; - -/** ping values above this are flagged to the user (ms) */ -const slowPingThreshold = 100; +import NetworkStatus from './NetworkStatus'; export default function NetworkLogPanel({ location }: PanelBaseProps) { + const statusRef = useScrollIntoView('status', location); const interfacesRef = useScrollIntoView('interfaces', location); const clientsRef = useScrollIntoView('clients', location); const logRef = useScrollIntoView('log', location); @@ -23,7 +16,9 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) { return ( <> Network - {isDocker && } +
+ +
{!isOntimeCloud && (
@@ -48,40 +43,3 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) { ); } - -function OntimeCloudStats() { - const ping = usePing(); - - /** - * Send immediate ping request, and keep sending on an interval - */ - useEffect(() => { - sendSocket(MessageTag.Ping, new Date()); - - const doPing = setInterval(() => { - sendSocket(MessageTag.Ping, new Date()); - }, 5000); - - return () => { - clearInterval(doPing); - }; - }, []); - - return ( - - - Ontime cloud - - - - - slowPingThreshold ? 'warning' : 'default'}>{ping}ms - - - - - ); -} diff --git a/apps/client/src/features/app-settings/panel/network-panel/NetworkStatus.tsx b/apps/client/src/features/app-settings/panel/network-panel/NetworkStatus.tsx new file mode 100644 index 000000000..d316d4347 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/network-panel/NetworkStatus.tsx @@ -0,0 +1,109 @@ +import { MaybeString, MessageTag } from 'ontime-types'; +import { MILLIS_PER_MINUTE } from 'ontime-utils'; +import { useEffect } from 'react'; + +import Tag from '../../../../common/components/tag/Tag'; +import useSessionStats from '../../../../common/hooks-query/useSessionStats'; +import { usePing } from '../../../../common/hooks/useSocket'; +import { useClientStore } from '../../../../common/stores/clientStore'; +import { formatDuration } from '../../../../common/utils/time'; +import { sendSocket } from '../../../../common/utils/socket'; +import { isDocker } from '../../../../externals'; +import * as Panel from '../../panel-utils/PanelUtils'; + +/** ping values above this are flagged to the user (ms) */ +const slowPingThreshold = 100; + +/** how often we ask the server for a ping (ms) */ +const pingInterval = 5000; + +/** + * Presents a date as the time elapsed since it happened + */ +function timeSince(date: MaybeString): string { + if (date === null) { + return 'Never'; + } + + const elapsed = Date.now() - new Date(date).getTime(); + if (elapsed < MILLIS_PER_MINUTE) { + return 'Just now'; + } + return `${formatDuration(elapsed)} ago`; +} + +export default function NetworkStatus() { + const { data, status, isError } = useSessionStats(); + // the client list is kept up to date over websocket, unlike the polled session stats + const clients = useClientStore((store) => store.clients); + + return ( + + + Server status + + {isError && Failed to load session data} + + + + + {Object.keys(clients).length} + + + + {data ? formatDuration(Date.now() - new Date(data.startedAt).getTime()) : '...'} + + + + {data ? timeSince(data.lastConnection) : '...'} + + + + {data ? timeSince(data.lastRequest) : '...'} + + + + {data ? data.timezone : '...'} + + {isDocker && } + + + + ); +} + +/** + * Ontime cloud users are not in the same network as the server + * we show the round trip time to help them qualify the connection + */ +function CloudPing() { + const ping = usePing(); + + /** + * Send immediate ping request, and keep sending on an interval + */ + useEffect(() => { + sendSocket(MessageTag.Ping, new Date()); + + const doPing = setInterval(() => { + sendSocket(MessageTag.Ping, new Date()); + }, pingInterval); + + return () => { + clearInterval(doPing); + }; + }, []); + + return ( + + + slowPingThreshold ? 'warning' : 'default'}>{ping}ms + + ); +} diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 358c2fcd3..e0fedb4da 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -67,6 +67,10 @@ const staticOptions = [ id: 'network', label: 'Network', secondary: [ + { + id: 'network__status', + label: 'Server status', + }, { id: 'network__interfaces', label: 'Network interfaces',