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',