mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-11 09:09:34 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e2baf1fa1 | |||
| 46e4ac94f1 | |||
| b5e1bccc70 | |||
| c840add9c5 |
@@ -15,6 +15,7 @@ export const RUNDOWN = ['rundown'];
|
|||||||
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
|
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
|
||||||
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
|
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
|
||||||
export const RUNTIME = ['runtimeStore'];
|
export const RUNTIME = ['runtimeStore'];
|
||||||
|
export const SESSION_STATS = ['sessionStats'];
|
||||||
export const URL_PRESETS = ['urlpresets'];
|
export const URL_PRESETS = ['urlpresets'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CSS_OVERRIDE = ['cssOverride'];
|
export const CSS_OVERRIDE = ['cssOverride'];
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
import { GetInfo, LinkOptions, SessionStats } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
import type { RequestOptions } from './requestOptions';
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const sessionPath = `${apiEntryUrl}/session`;
|
const sessionPath = `${apiEntryUrl}/session`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to retrieve statistics of the running session
|
||||||
|
*/
|
||||||
|
export async function getSessionStats(options?: RequestOptions): Promise<SessionStats> {
|
||||||
|
const res = await axios.get(sessionPath, { signal: options?.signal });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve application info
|
* HTTP request to retrieve application info
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import Select from '../select/Select';
|
|||||||
|
|
||||||
import style from './RedirectClientModal.module.scss';
|
import style from './RedirectClientModal.module.scss';
|
||||||
|
|
||||||
|
/** the first navigator view, used as the initial value of the view select */
|
||||||
|
const defaultView = `/${navigatorConstants[0].url}`;
|
||||||
|
|
||||||
interface RedirectClientModalProps {
|
interface RedirectClientModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
|
|||||||
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
|
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
|
||||||
const { data } = useUrlPresets();
|
const { data } = useUrlPresets();
|
||||||
const [path, setPath] = useState(currentPath);
|
const [path, setPath] = useState(currentPath);
|
||||||
const [selected, setSelected] = useState('/');
|
const [selected, setSelected] = useState(defaultView);
|
||||||
|
|
||||||
const { setRedirect } = setClientRemote;
|
const { setRedirect } = setClientRemote;
|
||||||
|
|
||||||
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
<Select
|
<Select
|
||||||
fluid
|
fluid
|
||||||
options={viewOptions}
|
options={viewOptions}
|
||||||
defaultValue={viewOptions[0].value}
|
defaultValue={defaultView}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
setSelected(value);
|
setSelected(value);
|
||||||
}}
|
}}
|
||||||
disabled={enabledPresets.length === 0}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
aria-label='Redirect to preset'
|
aria-label='Redirect to preset'
|
||||||
className={style.redirect}
|
className={style.redirect}
|
||||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
disabled={selected === currentPath}
|
||||||
onClick={() => handleRedirect(selected)}
|
onClick={() => handleRedirect(selected)}
|
||||||
>
|
>
|
||||||
Redirect <IoArrowForward />
|
Redirect <IoArrowForward />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
import Button from '../../../common/components/buttons/Button';
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
import Dialog from '../dialog/Dialog';
|
import Dialog from '../dialog/Dialog';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
|
|
||||||
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
|
|||||||
showCloseButton
|
showCloseButton
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
bodyElements={
|
bodyElements={
|
||||||
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
|
<Input
|
||||||
|
height='large'
|
||||||
|
placeholder='New name'
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (isKeyEnter(event) && canSubmit) {
|
||||||
|
handleRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
footerElements={
|
footerElements={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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<SessionStats>({
|
||||||
|
queryKey: SESSION_STATS,
|
||||||
|
queryFn: ({ signal }) => getSessionStats({ signal }),
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
refetchInterval: queryRefetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, status, isError, refetch };
|
||||||
|
}
|
||||||
@@ -15,11 +15,14 @@ const logger = createStore<LogStore>(() => ({
|
|||||||
logs: [],
|
logs: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** the log is kept in memory, we cap it to avoid it growing through a long show */
|
||||||
|
const maxLogEntries = 500;
|
||||||
|
|
||||||
export const useLogData = () => useStore(logger);
|
export const useLogData = () => useStore(logger);
|
||||||
|
|
||||||
export const addLog = (log: Log) =>
|
export const addLog = (log: Log) =>
|
||||||
logger.setState((state) => ({
|
logger.setState((state) => ({
|
||||||
logs: [log, ...state.logs],
|
logs: [log, ...state.logs].slice(0, maxLogEntries),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const clearLogs = () => logger.setState({ logs: [] });
|
export const clearLogs = () => logger.setState({ logs: [] });
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
|
|||||||
td[data-warn='true'] {
|
td[data-warn='true'] {
|
||||||
background-color: $orange-1300;
|
background-color: $orange-1300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tr[data-highlight='true'] {
|
||||||
|
background-color: $blue-1100;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.listGroup {
|
.listGroup {
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ export default function InfoNif() {
|
|||||||
// interfaces outside localhost wont have access
|
// interfaces outside localhost wont have access
|
||||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||||
const address = linkToOtherHost(nif.address);
|
const address = linkToOtherHost(nif.address);
|
||||||
|
// we show the address as it would be typed in a browser, including the port
|
||||||
|
const label = new URL(address).host;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}>
|
<CopyTag key={`${nif.name}-${nif.address}`} copyValue={address} onClick={() => handleClick(address)}>
|
||||||
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
|
{`${nif.name} - ${label}`} <IoArrowUp className={style.goIcon} />
|
||||||
</CopyTag>
|
</CopyTag>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ export default function LogExport() {
|
|||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Event log
|
Event log
|
||||||
<Button onClick={extract}>
|
<Button onClick={extract}>
|
||||||
Extract <IoArrowUp className={style.iconRotate} />
|
Open in new window <IoArrowUp className={style.iconRotate} />
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
|
<Panel.Paragraph>
|
||||||
|
Activity in this session. The log is not saved with the project and is cleared when Ontime restarts.
|
||||||
|
</Panel.Paragraph>
|
||||||
<Log />
|
<Log />
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
import { MessageTag } from 'ontime-types';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { usePing } from '../../../../common/hooks/useSocket';
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import { sendSocket } from '../../../../common/utils/socket';
|
|
||||||
import { isDocker } from '../../../../externals';
|
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import ClientControlPanel from './client-control/ClientControlPanel';
|
import ClientControlPanel from './client-control/ClientControlPanel';
|
||||||
|
import InfoNif from './NetworkInterfaces';
|
||||||
import LogExport from './NetworkLogExport';
|
import LogExport from './NetworkLogExport';
|
||||||
|
import NetworkStatus from './NetworkStatus';
|
||||||
|
|
||||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||||
|
const statusRef = useScrollIntoView<HTMLDivElement>('status', location);
|
||||||
|
const interfacesRef = useScrollIntoView<HTMLDivElement>('interfaces', location);
|
||||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Network</Panel.Header>
|
<Panel.Header>Network</Panel.Header>
|
||||||
{isDocker && (
|
<div ref={statusRef}>
|
||||||
<Panel.Section>
|
<NetworkStatus />
|
||||||
<OntimeCloudStats />
|
</div>
|
||||||
</Panel.Section>
|
{!isOntimeCloud && (
|
||||||
|
<div ref={interfacesRef}>
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Network interfaces</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Paragraph>
|
||||||
|
Addresses which can be used to reach Ontime from other devices in the network. <br />
|
||||||
|
Click an address to open it in a new window, or use the icon to copy it to the clipboard.
|
||||||
|
</Panel.Paragraph>
|
||||||
|
<InfoNif />
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={logRef}>
|
<div ref={logRef}>
|
||||||
<LogExport />
|
<LogExport />
|
||||||
@@ -31,29 +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 (
|
|
||||||
<Panel.SubHeader>
|
|
||||||
Ontime cloud
|
|
||||||
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
|
||||||
</Panel.SubHeader>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Server status</Panel.SubHeader>
|
||||||
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
|
{isError && <Panel.Error>Failed to load session data</Panel.Error>}
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Connected clients' description='Clients currently connected to this server' />
|
||||||
|
<Tag>{Object.keys(clients).length}</Tag>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Server running for' description='Time since the Ontime server was started' />
|
||||||
|
<Tag>{data ? formatDuration(Date.now() - new Date(data.startedAt).getTime()) : '...'}</Tag>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Last client connection' description='When a client last connected to the server' />
|
||||||
|
<Tag>{data ? timeSince(data.lastConnection) : '...'}</Tag>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Last integration request'
|
||||||
|
description='When the server last received a request from an integration'
|
||||||
|
/>
|
||||||
|
<Tag>{data ? timeSince(data.lastRequest) : '...'}</Tag>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Server time zone' description='Time zone used by the machine running Ontime' />
|
||||||
|
<Tag>{data ? data.timezone : '...'}</Tag>
|
||||||
|
</Panel.ListItem>
|
||||||
|
{isDocker && <CloudPing />}
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Ontime cloud latency'
|
||||||
|
description='Time for a message to travel to the server and back. Lower is better'
|
||||||
|
/>
|
||||||
|
<Tag variant={ping > slowPingThreshold ? 'warning' : 'default'}>{ping}ms</Tag>
|
||||||
|
</Panel.ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-10
@@ -1,11 +1,3 @@
|
|||||||
.fullWidth {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.halfWidth {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.halfWidthNoWrap {
|
.halfWidthNoWrap {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -16,6 +8,11 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self {
|
.muted {
|
||||||
background-color: $blue-1100;
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
// align section notes with the table contents
|
||||||
|
.note {
|
||||||
|
padding: 0 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-11
@@ -5,14 +5,42 @@ import { useState } from 'react';
|
|||||||
import Button from '../../../../../common/components/buttons/Button';
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
|
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
|
||||||
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
|
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
|
||||||
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
import Tag from '../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
|
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
|
||||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||||
import { cx } from '../../../../../common/utils/styleUtils';
|
import { openLink } from '../../../../../common/utils/linkUtils';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ClientControlPanel.module.scss';
|
import style from './ClientControlPanel.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the path a client is showing
|
||||||
|
* The full URL is available for copying or opening in a new window
|
||||||
|
*/
|
||||||
|
function ClientPath({ origin, path }: { origin: string; path: string }) {
|
||||||
|
// clients report their path on connection, we may not have it yet
|
||||||
|
if (!path) {
|
||||||
|
return <span className={style.muted}>unknown</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// origin is empty for clients which have not reported their location
|
||||||
|
if (!origin) {
|
||||||
|
return <span className={style.copiable}>{path}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullUrl = `${origin}${path}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip text={fullUrl} render={<span />}>
|
||||||
|
<CopyTag copyValue={fullUrl} size='small' onClick={() => openLink(fullUrl)}>
|
||||||
|
{path}
|
||||||
|
</CopyTag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientList() {
|
export default function ClientList() {
|
||||||
const id = useClientStore((store) => store.id);
|
const id = useClientStore((store) => store.id);
|
||||||
const clients = useClientStore((store) => store.clients);
|
const clients = useClientStore((store) => store.clients);
|
||||||
@@ -32,8 +60,19 @@ export default function ClientList() {
|
|||||||
redirectHandler.open();
|
redirectHandler.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
/**
|
||||||
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
* Clients are given by the server in connection order, which means rows move
|
||||||
|
* as clients come and go. We keep a stable order: self first, then by name
|
||||||
|
*/
|
||||||
|
const sortClients = (clientEntries: [string, Client][]) =>
|
||||||
|
clientEntries.sort(([keyA, clientA], [keyB, clientB]) => {
|
||||||
|
if (keyA === id) return -1;
|
||||||
|
if (keyB === id) return 1;
|
||||||
|
return clientA.name.localeCompare(clientB.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ontimeClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type === 'ontime'));
|
||||||
|
const otherClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type !== 'ontime'));
|
||||||
|
|
||||||
const targetClient: Client | undefined = clients[targetId];
|
const targetClient: Client | undefined = clients[targetId];
|
||||||
|
|
||||||
@@ -59,29 +98,37 @@ export default function ClientList() {
|
|||||||
)}
|
)}
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
|
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
|
||||||
|
<div className={style.note}>
|
||||||
|
<Panel.Description>
|
||||||
|
Ontime views connected to this server. Identify shows a marker in the client screen until you turn it off,
|
||||||
|
redirect sends the client to a different view.
|
||||||
|
</Panel.Description>
|
||||||
|
</div>
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={{ width: '20%' }}>Client Name</td>
|
<th style={{ width: '20%' }}>Client Name</th>
|
||||||
<td>Path</td>
|
<th>Path</th>
|
||||||
<td />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
|
||||||
{ontimeClients.map(([key, client]) => {
|
{ontimeClients.map(([key, client]) => {
|
||||||
const { identify, name, path } = client;
|
const { identify, name, path } = client;
|
||||||
const isCurrent = id === key;
|
const isCurrent = id === key;
|
||||||
return (
|
return (
|
||||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
<tr key={key} data-highlight={isCurrent}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
{isCurrent && <Tag>SELF</Tag>}
|
{isCurrent && <Tag>SELF</Tag>}
|
||||||
{name}
|
{name}
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<td className={style.copiable}>{path}</td>
|
<td>
|
||||||
|
<ClientPath origin={client.origin} path={path} />
|
||||||
|
</td>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
<Button
|
<Button
|
||||||
size='small'
|
size='small'
|
||||||
className={`${identify ? style.blink : ''}`}
|
|
||||||
disabled={isCurrent}
|
disabled={isCurrent}
|
||||||
variant={identify ? 'primary' : 'subtle'}
|
variant={identify ? 'primary' : 'subtle'}
|
||||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||||
@@ -117,14 +164,21 @@ export default function ClientList() {
|
|||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
|
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
|
||||||
|
<div className={style.note}>
|
||||||
|
<Panel.Description>
|
||||||
|
Connections which are not Ontime views, such as integrations and custom clients. These cannot be controlled
|
||||||
|
from Ontime.
|
||||||
|
</Panel.Description>
|
||||||
|
</div>
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td className={style.halfWidthNoWrap}>Client Name</td>
|
<th className={style.halfWidthNoWrap}>Client Name</th>
|
||||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
<th className={style.halfWidthNoWrap}>Client type</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
|
||||||
{otherClients.map(([key, client]) => {
|
{otherClients.map(([key, client]) => {
|
||||||
const { name, type } = client;
|
const { name, type } = client;
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ const staticOptions = [
|
|||||||
id: 'network',
|
id: 'network',
|
||||||
label: 'Network',
|
label: 'Network',
|
||||||
secondary: [
|
secondary: [
|
||||||
|
{
|
||||||
|
id: 'network__status',
|
||||||
|
label: 'Server status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'network__interfaces',
|
||||||
|
label: 'Network interfaces',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'network__log',
|
id: 'network__log',
|
||||||
label: 'Event log',
|
label: 'Event log',
|
||||||
|
|||||||
@@ -51,3 +51,13 @@ $info-hover: $section-white;
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
color: $info-gray;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $info-gray;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,115 +1,90 @@
|
|||||||
import { LogOrigin } from 'ontime-types';
|
import { LogLevel, LogOrigin } from 'ontime-types';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
|
import Input from '../../common/components/input/input/Input';
|
||||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './Log.module.scss';
|
import style from './Log.module.scss';
|
||||||
|
|
||||||
|
const allOrigins = [
|
||||||
|
LogOrigin.User,
|
||||||
|
LogOrigin.Client,
|
||||||
|
LogOrigin.Server,
|
||||||
|
LogOrigin.Playback,
|
||||||
|
LogOrigin.Rx,
|
||||||
|
LogOrigin.Tx,
|
||||||
|
];
|
||||||
|
|
||||||
export default function Log() {
|
export default function Log() {
|
||||||
const { logs: logData } = useLogData();
|
const { logs: logData } = useLogData();
|
||||||
|
|
||||||
const [showClient, setShowClient] = useState(true);
|
// log entries are not guaranteed to have an origin from the enum
|
||||||
const [showServer, setShowServer] = useState(true);
|
const [origins, setOrigins] = useState<Set<string>>(() => new Set<string>(allOrigins));
|
||||||
const [showRx, setShowRx] = useState(true);
|
const [onlyProblems, setOnlyProblems] = useState(false);
|
||||||
const [showTx, setShowTx] = useState(true);
|
const [search, setSearch] = useState('');
|
||||||
const [showPlayback, setShowPlayback] = useState(true);
|
|
||||||
const [showUser, setShowUser] = useState(true);
|
|
||||||
|
|
||||||
const matchers: LogOrigin[] = [];
|
const toggleOrigin = useCallback((origin: LogOrigin) => {
|
||||||
if (showUser) {
|
setOrigins((previous) => {
|
||||||
matchers.push(LogOrigin.User);
|
const newOrigins = new Set(previous);
|
||||||
}
|
if (newOrigins.has(origin)) {
|
||||||
if (showClient) {
|
newOrigins.delete(origin);
|
||||||
matchers.push(LogOrigin.Client);
|
} else {
|
||||||
}
|
newOrigins.add(origin);
|
||||||
if (showServer) {
|
}
|
||||||
matchers.push(LogOrigin.Server);
|
return newOrigins;
|
||||||
}
|
});
|
||||||
if (showRx) {
|
|
||||||
matchers.push(LogOrigin.Rx);
|
|
||||||
}
|
|
||||||
if (showTx) {
|
|
||||||
matchers.push(LogOrigin.Tx);
|
|
||||||
}
|
|
||||||
if (showPlayback) {
|
|
||||||
matchers.push(LogOrigin.Playback);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
|
|
||||||
|
|
||||||
const disableOthers = useCallback((toEnable: LogOrigin) => {
|
|
||||||
setShowUser(toEnable === LogOrigin.User);
|
|
||||||
setShowClient(toEnable === LogOrigin.Client);
|
|
||||||
setShowServer(toEnable === LogOrigin.Server);
|
|
||||||
setShowRx(toEnable === LogOrigin.Rx);
|
|
||||||
setShowTx(toEnable === LogOrigin.Tx);
|
|
||||||
setShowPlayback(toEnable === LogOrigin.Playback);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** middle click on an origin shows only that origin */
|
||||||
|
const showOnlyOrigin = useCallback((origin: LogOrigin) => {
|
||||||
|
setOrigins(new Set([origin]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchTerm = search.toLowerCase();
|
||||||
|
const filteredData = logData.filter((entry) => {
|
||||||
|
if (!origins.has(entry.origin)) return false;
|
||||||
|
if (onlyProblems && entry.level === LogLevel.Info) return false;
|
||||||
|
if (searchTerm && !entry.text.toLowerCase().includes(searchTerm)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFiltered = filteredData.length !== logData.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.InlineElements className={style.buttonBar}>
|
<Panel.InlineElements className={style.buttonBar}>
|
||||||
|
{allOrigins.map((origin) => (
|
||||||
|
<Button
|
||||||
|
key={origin}
|
||||||
|
variant={origins.has(origin) ? 'primary' : 'subtle'}
|
||||||
|
size='small'
|
||||||
|
onClick={() => toggleOrigin(origin)}
|
||||||
|
onAuxClick={() => showOnlyOrigin(origin)}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{origin}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
<Button
|
<Button
|
||||||
variant={showUser ? 'primary' : 'subtle'}
|
variant={onlyProblems ? 'primary' : 'subtle'}
|
||||||
size='small'
|
size='small'
|
||||||
onClick={() => setShowUser((s) => !s)}
|
onClick={() => setOnlyProblems((previous) => !previous)}
|
||||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
>
|
||||||
{LogOrigin.User}
|
Issues only
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={showClient ? 'primary' : 'subtle'}
|
|
||||||
size='small'
|
|
||||||
onClick={() => setShowClient((s) => !s)}
|
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{LogOrigin.Client}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={showServer ? 'primary' : 'subtle'}
|
|
||||||
size='small'
|
|
||||||
onClick={() => setShowServer((s) => !s)}
|
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{LogOrigin.Server}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={showPlayback ? 'primary' : 'subtle'}
|
|
||||||
size='small'
|
|
||||||
onClick={() => setShowPlayback((s) => !s)}
|
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{LogOrigin.Playback}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={showRx ? 'primary' : 'subtle'}
|
|
||||||
size='small'
|
|
||||||
onClick={() => setShowRx((s) => !s)}
|
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{LogOrigin.Rx}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={showTx ? 'primary' : 'subtle'}
|
|
||||||
size='small'
|
|
||||||
onClick={() => setShowTx((s) => !s)}
|
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{LogOrigin.Tx}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
|
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
|
||||||
<IoClose /> Clear
|
<IoClose /> Clear
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
|
<Panel.InlineElements className={style.buttonBar}>
|
||||||
|
<Input fluid placeholder='Filter messages' value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||||
|
<span className={style.count}>
|
||||||
|
{isFiltered ? `${filteredData.length} of ${logData.length}` : logData.length} entries
|
||||||
|
</span>
|
||||||
|
</Panel.InlineElements>
|
||||||
<ul className={style.log}>
|
<ul className={style.log}>
|
||||||
{filteredData.map((logEntry) => (
|
{filteredData.map((logEntry) => (
|
||||||
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
|
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
|
||||||
@@ -118,6 +93,9 @@ export default function Log() {
|
|||||||
<span className={style.msg}>{logEntry.text}</span>
|
<span className={style.msg}>{logEntry.text}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{filteredData.length === 0 && (
|
||||||
|
<li className={style.empty}>{logData.length === 0 ? 'No activity yet' : 'No entries match the filters'}</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user