mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +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 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'];
|
||||
|
||||
@@ -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<SessionStats> {
|
||||
const res = await axios.get(sessionPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,9 @@ import Select from '../select/Select';
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
|
||||
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
|
||||
const { data } = useUrlPresets();
|
||||
const [path, setPath] = useState(currentPath);
|
||||
const [selected, setSelected] = useState('/');
|
||||
const [selected, setSelected] = useState(defaultView);
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
defaultValue={defaultView}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setSelected(value);
|
||||
}}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
disabled={selected === currentPath}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
import Input from '../input/input/Input';
|
||||
|
||||
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
|
||||
showCloseButton
|
||||
onClose={onClose}
|
||||
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={
|
||||
<>
|
||||
|
||||
@@ -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: [],
|
||||
}));
|
||||
|
||||
/** 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 addLog = (log: Log) =>
|
||||
logger.setState((state) => ({
|
||||
logs: [log, ...state.logs],
|
||||
logs: [log, ...state.logs].slice(0, maxLogEntries),
|
||||
}));
|
||||
|
||||
export const clearLogs = () => logger.setState({ logs: [] });
|
||||
|
||||
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
|
||||
td[data-warn='true'] {
|
||||
background-color: $orange-1300;
|
||||
}
|
||||
|
||||
tr[data-highlight='true'] {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
|
||||
@@ -19,10 +19,12 @@ export default function InfoNif() {
|
||||
// interfaces outside localhost wont have access
|
||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||
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 (
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}>
|
||||
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
|
||||
<CopyTag key={`${nif.name}-${nif.address}`} copyValue={address} onClick={() => handleClick(address)}>
|
||||
{`${nif.name} - ${label}`} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -19,10 +19,13 @@ export default function LogExport() {
|
||||
<Panel.SubHeader>
|
||||
Event log
|
||||
<Button onClick={extract}>
|
||||
Extract <IoArrowUp className={style.iconRotate} />
|
||||
Open in new window <IoArrowUp className={style.iconRotate} />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<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 />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import { MessageTag } from 'ontime-types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { sendSocket } from '../../../../common/utils/socket';
|
||||
import { isDocker } 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';
|
||||
import NetworkStatus from './NetworkStatus';
|
||||
|
||||
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 logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
{isDocker && (
|
||||
<Panel.Section>
|
||||
<OntimeCloudStats />
|
||||
</Panel.Section>
|
||||
<div ref={statusRef}>
|
||||
<NetworkStatus />
|
||||
</div>
|
||||
{!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}>
|
||||
<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 {
|
||||
width: 50%;
|
||||
white-space: nowrap;
|
||||
@@ -16,6 +8,11 @@
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.self {
|
||||
background-color: $blue-1100;
|
||||
.muted {
|
||||
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 { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
|
||||
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
|
||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||
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 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() {
|
||||
const id = useClientStore((store) => store.id);
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
@@ -32,8 +60,19 @@ export default function ClientList() {
|
||||
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];
|
||||
|
||||
@@ -59,29 +98,37 @@ export default function ClientList() {
|
||||
)}
|
||||
<Panel.Section>
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ width: '20%' }}>Client Name</td>
|
||||
<td>Path</td>
|
||||
<td />
|
||||
<th style={{ width: '20%' }}>Client Name</th>
|
||||
<th>Path</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
|
||||
{ontimeClients.map(([key, client]) => {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||
<tr key={key} data-highlight={isCurrent}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
{isCurrent && <Tag>SELF</Tag>}
|
||||
{name}
|
||||
</Panel.InlineElements>
|
||||
<td className={style.copiable}>{path}</td>
|
||||
<td>
|
||||
<ClientPath origin={client.origin} path={path} />
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
disabled={isCurrent}
|
||||
variant={identify ? 'primary' : 'subtle'}
|
||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||
@@ -117,14 +164,21 @@ export default function ClientList() {
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.halfWidthNoWrap}>Client Name</td>
|
||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
||||
<th className={style.halfWidthNoWrap}>Client Name</th>
|
||||
<th className={style.halfWidthNoWrap}>Client type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
|
||||
{otherClients.map(([key, client]) => {
|
||||
const { name, type } = client;
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ const staticOptions = [
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__status',
|
||||
label: 'Server status',
|
||||
},
|
||||
{
|
||||
id: 'network__interfaces',
|
||||
label: 'Network interfaces',
|
||||
},
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Event log',
|
||||
|
||||
@@ -51,3 +51,13 @@ $info-hover: $section-white;
|
||||
flex-wrap: wrap;
|
||||
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 { IoClose } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import Input from '../../common/components/input/input/Input';
|
||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
|
||||
import style from './Log.module.scss';
|
||||
|
||||
const allOrigins = [
|
||||
LogOrigin.User,
|
||||
LogOrigin.Client,
|
||||
LogOrigin.Server,
|
||||
LogOrigin.Playback,
|
||||
LogOrigin.Rx,
|
||||
LogOrigin.Tx,
|
||||
];
|
||||
|
||||
export default function Log() {
|
||||
const { logs: logData } = useLogData();
|
||||
|
||||
const [showClient, setShowClient] = useState(true);
|
||||
const [showServer, setShowServer] = useState(true);
|
||||
const [showRx, setShowRx] = useState(true);
|
||||
const [showTx, setShowTx] = useState(true);
|
||||
const [showPlayback, setShowPlayback] = useState(true);
|
||||
const [showUser, setShowUser] = useState(true);
|
||||
// log entries are not guaranteed to have an origin from the enum
|
||||
const [origins, setOrigins] = useState<Set<string>>(() => new Set<string>(allOrigins));
|
||||
const [onlyProblems, setOnlyProblems] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const matchers: LogOrigin[] = [];
|
||||
if (showUser) {
|
||||
matchers.push(LogOrigin.User);
|
||||
}
|
||||
if (showClient) {
|
||||
matchers.push(LogOrigin.Client);
|
||||
}
|
||||
if (showServer) {
|
||||
matchers.push(LogOrigin.Server);
|
||||
}
|
||||
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);
|
||||
const toggleOrigin = useCallback((origin: LogOrigin) => {
|
||||
setOrigins((previous) => {
|
||||
const newOrigins = new Set(previous);
|
||||
if (newOrigins.has(origin)) {
|
||||
newOrigins.delete(origin);
|
||||
} else {
|
||||
newOrigins.add(origin);
|
||||
}
|
||||
return newOrigins;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** 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 (
|
||||
<>
|
||||
<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
|
||||
variant={showUser ? 'primary' : 'subtle'}
|
||||
variant={onlyProblems ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onClick={() => setOnlyProblems((previous) => !previous)}
|
||||
>
|
||||
{LogOrigin.User}
|
||||
</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}
|
||||
Issues only
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
|
||||
<IoClose /> Clear
|
||||
</Button>
|
||||
</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}>
|
||||
{filteredData.map((logEntry) => (
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
{filteredData.length === 0 && (
|
||||
<li className={style.empty}>{logData.length === 0 ? 'No activity yet' : 'No entries match the filters'}</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user