mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-13 10:09:35 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e2baf1fa1 | |||
| 46e4ac94f1 | |||
| b5e1bccc70 | |||
| c840add9c5 |
@@ -30,7 +30,7 @@
|
|||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.80.0",
|
"react-hook-form": "^7.80.0",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.0.1",
|
||||||
"react-virtuoso": "^4.18.7",
|
"react-virtuoso": "^4.18.7",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,43 +1,20 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
|
||||||
|
|
||||||
.emptyContainer {
|
.emptyContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: $white-10;
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(100%, 14rem);
|
width: min(100%, 24rem);
|
||||||
margin: 0 auto -1.5rem;
|
margin-inline: auto;
|
||||||
opacity: 0.6;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
display: block;
|
display: block;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
font-size: clamp(1rem, 1.55vw, 1.5rem);
|
font-size: 2em;
|
||||||
line-height: 1.35;
|
max-width: min(100%, 600px);
|
||||||
max-width: min(100%, 40rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
color: $error-red;
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
opacity: 0.35;
|
|
||||||
filter: grayscale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorIcon {
|
|
||||||
display: block;
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
margin: -0.125rem auto 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
import { IoWarningOutline } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
@@ -10,18 +9,12 @@ interface EmptyProps {
|
|||||||
text?: string;
|
text?: string;
|
||||||
injectedStyles?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
|
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
|
||||||
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
|
|
||||||
style={injectedStyles}
|
|
||||||
role={variant === 'error' ? 'alert' : undefined}
|
|
||||||
>
|
|
||||||
<EmptyImage className={style.empty} />
|
<EmptyImage className={style.empty} />
|
||||||
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
|
|
||||||
{text && <span className={style.text}>{text}</span>}
|
{text && <span className={style.text}>{text}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
.fill {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
place-items: start center;
|
|
||||||
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { cx } from '../../utils/styleUtils';
|
|
||||||
import Empty from './Empty';
|
|
||||||
|
|
||||||
import style from './EmptyFill.module.scss';
|
|
||||||
|
|
||||||
interface EmptyFillProps {
|
|
||||||
text?: string;
|
|
||||||
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Container-filling empty/loading state for panels and grid/flex cells. */
|
|
||||||
export default function EmptyFill({ text, className }: EmptyFillProps) {
|
|
||||||
return (
|
|
||||||
<div className={cx([style.fill, className])}>
|
|
||||||
<Empty text={text} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
box-sizing: border-box; /* reset */
|
box-sizing: border-box; /* reset */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%; /* restrict the page width to viewport */
|
width: 100%; /* restrict the page width to viewport */
|
||||||
height: 100dvh;
|
height: 100vh;
|
||||||
|
|
||||||
font-family: var(--font-family-override, $viewer-font-family);
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
@@ -16,6 +16,5 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
padding-top: 5rem;
|
||||||
padding-block: 5rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
|
|||||||
interface EmptyPageProps {
|
interface EmptyPageProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
injectedStyles?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
variant?: 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
|
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.page}>
|
<div className={style.page}>
|
||||||
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
|
<Empty text={text} injectedStyles={injectedStyles} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,9 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
|||||||
<tbody className={style.emptyContainer}>
|
<tbody className={style.emptyContainer}>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={99} className={style.emptyCell}>
|
<td colSpan={99} className={style.emptyCell}>
|
||||||
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
|
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||||
|
<span className={style.text}>{text}</span>
|
||||||
{handleAddNew && (
|
{handleAddNew && (
|
||||||
<div className={style.inline}>
|
<div className={style.inline}>
|
||||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
@@ -11,7 +10,6 @@ import { cx } from '../../common/utils/styleUtils';
|
|||||||
import { throttle } from '../../common/utils/throttle';
|
import { throttle } from '../../common/utils/throttle';
|
||||||
import { getDefaultFormat } from '../../common/utils/time';
|
import { getDefaultFormat } from '../../common/utils/time';
|
||||||
import { isTouchDevice } from '../../externals';
|
import { isTouchDevice } from '../../externals';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
|
||||||
import Loader from '../../views/common/loader/Loader';
|
import Loader from '../../views/common/loader/Loader';
|
||||||
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
||||||
import FollowButton from './follow-button/FollowButton';
|
import FollowButton from './follow-button/FollowButton';
|
||||||
@@ -37,7 +35,7 @@ export default function OperatorLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Operator {...data} />;
|
return <Operator {...data} />;
|
||||||
@@ -45,7 +43,6 @@ export default function OperatorLoader() {
|
|||||||
|
|
||||||
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
||||||
const selectedEventId = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
||||||
|
|
||||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||||
@@ -116,7 +113,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
||||||
|
|
||||||
const canEdit = shouldEdit && subscribe.length;
|
const canEdit = shouldEdit && subscribe.length;
|
||||||
const hasEvents = rundown.order.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.operatorContainer} data-testid='operator-view'>
|
<div className={style.operatorContainer} data-testid='operator-view'>
|
||||||
@@ -131,121 +127,117 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasEvents ? (
|
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
<EmptyFill text={getLocalizedString('common.no_data')} />
|
{rundown.order.map((entryId) => {
|
||||||
) : (
|
const entry = rundown.entries[entryId];
|
||||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
if (isOntimeEvent(entry)) {
|
||||||
{rundown.order.map((entryId) => {
|
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||||
const entry = rundown.entries[entryId];
|
// hide past events (if setting) and skipped events
|
||||||
if (isOntimeEvent(entry)) {
|
if ((hidePast && isPast) || entry.skip) {
|
||||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
return null;
|
||||||
// hide past events (if setting) and skipped events
|
}
|
||||||
if ((hidePast && isPast) || entry.skip) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||||
entry,
|
entry,
|
||||||
mainSource,
|
mainSource,
|
||||||
secondarySource,
|
secondarySource,
|
||||||
subscribe,
|
subscribe,
|
||||||
customFields,
|
customFields,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OperatorEvent
|
<OperatorEvent
|
||||||
|
key={entry.id}
|
||||||
|
id={entry.id}
|
||||||
|
colour={entry.colour}
|
||||||
|
cue={entry.cue}
|
||||||
|
main={mainField}
|
||||||
|
secondary={secondaryField}
|
||||||
|
timeStart={entry.timeStart}
|
||||||
|
duration={entry.duration}
|
||||||
|
delay={entry.delay}
|
||||||
|
dayOffset={entry.dayOffset}
|
||||||
|
isLinkedToLoaded={isLinkedToLoaded}
|
||||||
|
isSelected={isLoaded}
|
||||||
|
isPast={isPast}
|
||||||
|
selectedRef={isLoaded ? selectedRef : undefined}
|
||||||
|
showStart={showStart}
|
||||||
|
subscribed={subscribedData}
|
||||||
|
totalGap={totalGap}
|
||||||
|
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeGroup(entry)) {
|
||||||
|
const { isPast } = rundownMetadata[entry.id];
|
||||||
|
|
||||||
|
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
|
||||||
|
|
||||||
|
if (hidePast && isPast && !isCurrentParent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={entry.id}>
|
||||||
|
<OperatorGroup
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
id={entry.id}
|
title={entry.title}
|
||||||
colour={entry.colour}
|
colour={entry.colour}
|
||||||
cue={entry.cue}
|
count={entry.entries.length}
|
||||||
main={mainField}
|
|
||||||
secondary={secondaryField}
|
|
||||||
timeStart={entry.timeStart}
|
|
||||||
duration={entry.duration}
|
duration={entry.duration}
|
||||||
delay={entry.delay}
|
|
||||||
dayOffset={entry.dayOffset}
|
|
||||||
isLinkedToLoaded={isLinkedToLoaded}
|
|
||||||
isSelected={isLoaded}
|
|
||||||
isPast={isPast}
|
|
||||||
selectedRef={isLoaded ? selectedRef : undefined}
|
|
||||||
showStart={showStart}
|
|
||||||
subscribed={subscribedData}
|
|
||||||
totalGap={totalGap}
|
|
||||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
{entry.entries.map((nestedEntryId) => {
|
||||||
}
|
const nestedEntry = rundown.entries[nestedEntryId];
|
||||||
|
if (!isOntimeEvent(nestedEntry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||||
const { isPast } = rundownMetadata[entry.id];
|
|
||||||
|
|
||||||
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
|
// hide past events (if setting) and skipped events
|
||||||
|
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (hidePast && isPast && !isCurrentParent) {
|
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||||
return null;
|
nestedEntry,
|
||||||
}
|
mainSource,
|
||||||
|
secondarySource,
|
||||||
|
subscribe,
|
||||||
|
customFields,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<OperatorEvent
|
||||||
<OperatorGroup
|
key={nestedEntry.id}
|
||||||
key={entry.id}
|
id={nestedEntry.id}
|
||||||
title={entry.title}
|
colour={nestedEntry.colour}
|
||||||
colour={entry.colour}
|
cue={nestedEntry.cue}
|
||||||
count={entry.entries.length}
|
main={mainField}
|
||||||
duration={entry.duration}
|
secondary={secondaryField}
|
||||||
/>
|
timeStart={nestedEntry.timeStart}
|
||||||
{entry.entries.map((nestedEntryId) => {
|
duration={nestedEntry.duration}
|
||||||
const nestedEntry = rundown.entries[nestedEntryId];
|
delay={nestedEntry.delay}
|
||||||
if (!isOntimeEvent(nestedEntry)) {
|
dayOffset={nestedEntry.dayOffset}
|
||||||
return null;
|
isLinkedToLoaded={isLinkedToLoaded}
|
||||||
}
|
isSelected={isLoaded}
|
||||||
|
isPast={isPast}
|
||||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
groupColour={entry.colour}
|
||||||
|
selectedRef={isLoaded ? selectedRef : undefined}
|
||||||
// hide past events (if setting) and skipped events
|
showStart={showStart}
|
||||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
subscribed={subscribedData}
|
||||||
return null;
|
totalGap={totalGap}
|
||||||
}
|
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||||
|
/>
|
||||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
);
|
||||||
nestedEntry,
|
})}
|
||||||
mainSource,
|
</Fragment>
|
||||||
secondarySource,
|
);
|
||||||
subscribe,
|
}
|
||||||
customFields,
|
return null;
|
||||||
);
|
})}
|
||||||
|
</div>
|
||||||
return (
|
|
||||||
<OperatorEvent
|
|
||||||
key={nestedEntry.id}
|
|
||||||
id={nestedEntry.id}
|
|
||||||
colour={nestedEntry.colour}
|
|
||||||
cue={nestedEntry.cue}
|
|
||||||
main={mainField}
|
|
||||||
secondary={secondaryField}
|
|
||||||
timeStart={nestedEntry.timeStart}
|
|
||||||
duration={nestedEntry.duration}
|
|
||||||
delay={nestedEntry.delay}
|
|
||||||
dayOffset={nestedEntry.dayOffset}
|
|
||||||
isLinkedToLoaded={isLinkedToLoaded}
|
|
||||||
isSelected={isLoaded}
|
|
||||||
isPast={isPast}
|
|
||||||
groupColour={entry.colour}
|
|
||||||
selectedRef={isLoaded ? selectedRef : undefined}
|
|
||||||
showStart={showStart}
|
|
||||||
subscribed={subscribedData}
|
|
||||||
totalGap={totalGap}
|
|
||||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
import Empty from '../../common/components/state/Empty';
|
||||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
|
||||||
import Rundown from './Rundown';
|
import Rundown from './Rundown';
|
||||||
|
|
||||||
export default memo(RundownList);
|
export default memo(RundownList);
|
||||||
function RundownList() {
|
function RundownList() {
|
||||||
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
||||||
const featureData = useRundownEditor();
|
const featureData = useRundownEditor();
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
|
|
||||||
// avoid showing the editable empty state before we know whether the rundown is actually empty
|
const isLoading = status !== 'success' || !data || !rundownMetadata;
|
||||||
if (status === 'pending') {
|
|
||||||
return <EmptyFill text='Loading…' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'error') {
|
if (isLoading) {
|
||||||
return <EmptyFill text={getLocalizedString('common.no_data')} />;
|
return <Empty text='Connecting to server' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useEffect, useMemo } from 'react';
|
import { memo, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||||
@@ -12,7 +13,7 @@ import { makeRundownColumns } from './makeRundownColumns';
|
|||||||
|
|
||||||
export default memo(RundownTable);
|
export default memo(RundownTable);
|
||||||
function RundownTable() {
|
function RundownTable() {
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||||
const { editorMode } = useEditorFollowMode();
|
const { editorMode } = useEditorFollowMode();
|
||||||
const source = useLoadedRundownSource();
|
const source = useLoadedRundownSource();
|
||||||
@@ -31,10 +32,16 @@ function RundownTable() {
|
|||||||
|
|
||||||
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
|
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
|
||||||
|
|
||||||
|
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={actions}>
|
<EntryActionsProvider actions={actions}>
|
||||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||||
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
{isLoading ? (
|
||||||
|
<EmptyPage text='Loading...' />
|
||||||
|
) : (
|
||||||
|
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
||||||
|
)}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
</EntryActionsProvider>
|
</EntryActionsProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Backstage {...data} />;
|
return <Backstage {...data} />;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background-color: var(--background-color-override, $viewer-background-color);
|
background-color: var(--background-color-override, $viewer-background-color);
|
||||||
height: 100dvh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
|
|||||||
height: $dot-size;
|
height: $dot-size;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--accent-color-override, $ontime-color);
|
background-color: var(--accent-color-override, $ontime-color);
|
||||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||||
|
|
||||||
&:nth-child(1) {
|
&:nth-child(1) {
|
||||||
left: $dot-size;
|
left: $dot-size;
|
||||||
animation: lds-ellipsis1 1s infinite;
|
animation: lds-ellipsis1 0.6s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
left: $dot-size;
|
left: $dot-size;
|
||||||
animation: lds-ellipsis2 1s infinite;
|
animation: lds-ellipsis2 0.6s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
&:nth-child(3) {
|
||||||
left: calc($dot-size + $dot-spacing);
|
left: calc($dot-size + $dot-spacing);
|
||||||
animation: lds-ellipsis2 1s infinite;
|
animation: lds-ellipsis2 0.6s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(4) {
|
&:nth-child(4) {
|
||||||
left: calc($dot-size + 2 * $dot-spacing);
|
left: calc($dot-size + 2 * $dot-spacing);
|
||||||
animation: lds-ellipsis3 1s infinite;
|
animation: lds-ellipsis3 0.6s infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,21 @@ $item-height: 3.5rem;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
|
||||||
|
button {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__content {
|
||||||
|
max-width: none;
|
||||||
|
|
||||||
|
span {
|
||||||
|
max-width: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2.25rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-container {
|
.list-container {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Countdown {...data} />;
|
return <Countdown {...data} />;
|
||||||
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
|
|||||||
|
|
||||||
{!hasEvents && (
|
{!hasEvents && (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('common.no_data')} />
|
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
|||||||
if (subscriptions.length === 0) {
|
if (subscriptions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('countdown.select_event')} />
|
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
|
||||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||||
<IoAdd /> Add
|
<IoAdd /> Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
|||||||
if (subscribedEvents.length === 0) {
|
if (subscribedEvents.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('countdown.select_event')} />
|
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
|
||||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||||
<IoAdd /> Add
|
<IoAdd /> Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
|||||||
if (eventsToShow.length === 0) {
|
if (eventsToShow.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('countdown.all_have_finished')} />
|
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
|
|||||||
import { memo, use, useMemo } from 'react';
|
import { memo, use, useMemo } from 'react';
|
||||||
|
|
||||||
import Select from '../../common/components/select/Select';
|
import Select from '../../common/components/select/Select';
|
||||||
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||||
@@ -33,34 +34,40 @@ function CuesheetTableWrapper({
|
|||||||
const preset = use(PresetContext);
|
const preset = use(PresetContext);
|
||||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||||
[customFields, cuesheetMode, preset],
|
[customFields, cuesheetMode, preset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CuesheetDnd columns={columns}>
|
<CuesheetDnd columns={columns}>
|
||||||
<CuesheetTable
|
{isLoading ? (
|
||||||
columns={columns}
|
<EmptyPage text='Loading...' />
|
||||||
source={source}
|
) : (
|
||||||
cuesheetMode={cuesheetMode}
|
<CuesheetTable
|
||||||
tableRoot='cuesheet'
|
columns={columns}
|
||||||
setCuesheetMode={setCuesheetMode}
|
source={source}
|
||||||
isCurrentRundown={isCurrentRundown}
|
cuesheetMode={cuesheetMode}
|
||||||
insertElement={
|
tableRoot='cuesheet'
|
||||||
<>
|
setCuesheetMode={setCuesheetMode}
|
||||||
<RundownSelect
|
isCurrentRundown={isCurrentRundown}
|
||||||
cuesheetMode={cuesheetMode}
|
insertElement={
|
||||||
selectedRundownId={selectedRundownId}
|
<>
|
||||||
loadedRundownId={loadedRundownId}
|
<RundownSelect
|
||||||
setSelectedRundownId={setSelectedRundownId}
|
cuesheetMode={cuesheetMode}
|
||||||
projectRundowns={projectRundowns}
|
selectedRundownId={selectedRundownId}
|
||||||
/>
|
loadedRundownId={loadedRundownId}
|
||||||
</>
|
setSelectedRundownId={setSelectedRundownId}
|
||||||
}
|
projectRundowns={projectRundowns}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
|
|
||||||
@include rows.cuesheet-row-columns($table-header-font-size);
|
@include rows.cuesheet-row-columns($table-header-font-size);
|
||||||
|
|
||||||
.tableLoading {
|
|
||||||
grid-area: table;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cuesheet {
|
.cuesheet {
|
||||||
font-size: $table-font-size;
|
font-size: $table-font-size;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
TableVirtuosoHandle,
|
TableVirtuosoHandle,
|
||||||
} from 'react-virtuoso';
|
} from 'react-virtuoso';
|
||||||
|
|
||||||
import EmptyFill from '../../../common/components/state/EmptyFill';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||||
@@ -19,7 +19,6 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
|||||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
import { useCuesheetPermissions } from '../useTablePermissions';
|
import { useCuesheetPermissions } from '../useTablePermissions';
|
||||||
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||||
@@ -65,7 +64,6 @@ export default function CuesheetTable({
|
|||||||
}: CuesheetTableProps) {
|
}: CuesheetTableProps) {
|
||||||
const { flatRundown, status, selectedEventId } = source;
|
const { flatRundown, status, selectedEventId } = source;
|
||||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||||
|
|
||||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||||
@@ -230,13 +228,10 @@ export default function CuesheetTable({
|
|||||||
});
|
});
|
||||||
}, [cuesheetMode, hideIndexColumn, table]);
|
}, [cuesheetMode, hideIndexColumn, table]);
|
||||||
|
|
||||||
// avoid showing the editable empty state before we know whether the rundown is actually empty
|
const isLoading = !flatRundown || status === 'pending';
|
||||||
if (status === 'pending') {
|
|
||||||
return <EmptyFill text='Loading…' className={style.tableLoading} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'error') {
|
if (isLoading) {
|
||||||
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
|
return <EmptyPage text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
$content-width: min(100%, 1100px);
|
|
||||||
|
|
||||||
.project {
|
.project {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
box-sizing: border-box; /* reset */
|
box-sizing: border-box; /* reset */
|
||||||
@@ -18,104 +16,56 @@ $content-width: min(100%, 1100px);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
/* =================== HEADER ===================*/
|
|
||||||
|
|
||||||
.project-header {
|
|
||||||
width: $content-width;
|
|
||||||
margin-inline: auto;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: clamp(12px, 2vw, 24px);
|
|
||||||
|
|
||||||
padding-bottom: $view-element-gap;
|
|
||||||
border-bottom: 1px solid $white-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
max-width: min(200px, 30vw);
|
max-width: min(200px, 30vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: $header-font-size;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: $base-font-size;
|
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =================== CONTENT ===================*/
|
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: $content-width;
|
max-height: 100%;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
width: min(calc(100vw - 4rem), 960px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
gap: $view-element-gap;
|
gap: $view-element-gap;
|
||||||
|
|
||||||
padding-block: $view-element-gap;
|
|
||||||
padding-bottom: 10vh;
|
padding-bottom: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__card {
|
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
|
||||||
border-radius: $element-border-radius;
|
|
||||||
padding: $view-block-padding $view-inline-padding;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info__media {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: $view-element-gap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info__media .info__value {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info__label {
|
.info__label {
|
||||||
font-size: $timer-label-size;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__value {
|
.info__value {
|
||||||
white-space: break-spaces;
|
white-space: break-spaces;
|
||||||
line-height: 1.35;
|
}
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
.info__custom {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__image-container {
|
.info__image-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 0 0 min(192px, 25%);
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__image {
|
.info__image {
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link.info__value {
|
.link.info__value {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
gap: 0.35em;
|
gap: $view-element-gap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $action-text-color;
|
color: $action-text-color;
|
||||||
|
|
||||||
@@ -128,13 +78,12 @@ $content-width: min(100%, 1100px);
|
|||||||
/* =================== MOBILE ===================*/
|
/* =================== MOBILE ===================*/
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.project {
|
.project {
|
||||||
.project-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.logo img {
|
.logo img {
|
||||||
height: min(50px, 10vh);
|
height: min(50px, 10vh);
|
||||||
}
|
}
|
||||||
|
.info__image-container {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { OntimeView } from 'ontime-types';
|
import { OntimeView } from 'ontime-types';
|
||||||
import { type ReactNode, useState } from 'react';
|
|
||||||
import { IoOpenOutline } from 'react-icons/io5';
|
import { IoOpenOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
@@ -22,7 +21,7 @@ export default function ProjectInfoLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ProjectInfo {...data} />;
|
return <ProjectInfo {...data} />;
|
||||||
@@ -42,49 +41,56 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
||||||
<EmptyPage text={getLocalizedString('common.no_data')} />
|
<EmptyPage text={getLocalizedString('common.no_data')} />;
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
||||||
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
||||||
{hasHeader && (
|
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
||||||
<div className='project-header'>
|
|
||||||
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
|
||||||
<div className='project-header__text'>
|
|
||||||
{projectData.title && <div className='title'>{projectData.title}</div>}
|
|
||||||
{projectData.description && <div className='description'>{projectData.description}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='info'>
|
<div className='info'>
|
||||||
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
|
{projectData.title && (
|
||||||
|
<div>
|
||||||
|
<div className='info__label'>{getLocalizedString('project.title')}</div>
|
||||||
|
<div className='info__value'>{projectData.title}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{projectData.description && (
|
||||||
|
<div>
|
||||||
|
<div className='info__label'>{getLocalizedString('project.description')}</div>
|
||||||
|
<div className='info__value'>{projectData.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{projectData.info && (
|
||||||
|
<div>
|
||||||
|
<div className='info__label'>{getLocalizedString('project.info')}</div>
|
||||||
|
<div className='info__value'>{projectData.info}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{projectData.url && (
|
{projectData.url && (
|
||||||
<div className='info__card'>
|
<div>
|
||||||
<div className='info__label'>{getLocalizedString('project.url')}</div>
|
<div className='info__label'>{getLocalizedString('project.url')}</div>
|
||||||
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
|
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
|
||||||
{projectData.url}
|
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||||
<IoOpenOutline style={{ fontSize: '1em' }} />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{projectData.custom.map((info, idx) => {
|
{projectData.custom.map((info, idx) => {
|
||||||
|
const hasUrl = Boolean(info.url);
|
||||||
return (
|
return (
|
||||||
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
|
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
|
||||||
<div key={`${info.title}-${idx}`} className='info__card'>
|
<div key={`${info.title}-${idx}`} className='info__custom'>
|
||||||
{info.title && <div className='info__label'>{info.title}</div>}
|
{hasUrl && (
|
||||||
{info.url ? (
|
<div className='info__image-container'>
|
||||||
<div className='info__media'>
|
<img className='info__image' src={info.url} loading='lazy' />
|
||||||
<InfoImage src={info.url} />
|
|
||||||
{info.value && <div className='info__value'>{info.value}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
info.value && <div className='info__value'>{info.value}</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<div className='info__label'>{info.title}</div>
|
||||||
|
<div className='info__value'>{info.value}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -92,31 +98,3 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InfoCardProps {
|
|
||||||
label: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoCard({ label, children }: InfoCardProps) {
|
|
||||||
return (
|
|
||||||
<div className='info__card'>
|
|
||||||
<div className='info__label'>{label}</div>
|
|
||||||
<div className='info__value'>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoImage({ src }: { src: string }) {
|
|
||||||
const [hasError, setHasError] = useState(false);
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='info__image-container'>
|
|
||||||
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function StudioLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Studio {...data} />;
|
return <Studio {...data} />;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { OntimeView } from 'ontime-types';
|
import { OntimeView } from 'ontime-types';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
@@ -30,7 +29,7 @@ export default function TimelinePageLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TimelinePage {...data} />;
|
return <TimelinePage {...data} />;
|
||||||
@@ -74,7 +73,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
|||||||
totalDuration={totalDuration}
|
totalDuration={totalDuration}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyFill text={getLocalizedString('common.no_data')} />
|
<EmptyPage text={getLocalizedString('common.no_data')} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function TimerLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Timer {...data} />;
|
return <Timer {...data} />;
|
||||||
|
|||||||
Generated
+11
-40
@@ -137,8 +137,8 @@ importers:
|
|||||||
specifier: 5.6.0
|
specifier: 5.6.0
|
||||||
version: 5.6.0(react@19.2.7)
|
version: 5.6.0(react@19.2.7)
|
||||||
react-router:
|
react-router:
|
||||||
specifier: ^8.3.0
|
specifier: ^8.0.1
|
||||||
version: 8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
react-virtuoso:
|
react-virtuoso:
|
||||||
specifier: ^4.18.7
|
specifier: ^4.18.7
|
||||||
version: 4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
@@ -1508,9 +1508,6 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.30':
|
'@jridgewell/trace-mapping@0.3.30':
|
||||||
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
||||||
|
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
|
||||||
|
|
||||||
'@lezer/common@1.5.1':
|
'@lezer/common@1.5.1':
|
||||||
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||||
|
|
||||||
@@ -2975,8 +2972,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
acorn@8.18.0:
|
acorn@8.16.0:
|
||||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -3729,10 +3726,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
fs-extra@11.4.0:
|
|
||||||
resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==}
|
|
||||||
engines: {node: '>=14.14'}
|
|
||||||
|
|
||||||
fs-extra@7.0.1:
|
fs-extra@7.0.1:
|
||||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||||
engines: {node: '>=6 <7 || >=8'}
|
engines: {node: '>=6 <7 || >=8'}
|
||||||
@@ -4056,9 +4049,6 @@ packages:
|
|||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
jsonfile@6.2.1:
|
|
||||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
|
||||||
|
|
||||||
jwa@2.0.0:
|
jwa@2.0.0:
|
||||||
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
|
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
|
||||||
|
|
||||||
@@ -4605,8 +4595,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
react-router@8.3.0:
|
react-router@8.0.1:
|
||||||
resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==}
|
resolution: {integrity: sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==}
|
||||||
engines: {node: '>=22.22.0'}
|
engines: {node: '>=22.22.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=19.2.7'
|
react: '>=19.2.7'
|
||||||
@@ -6313,7 +6303,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
cross-dirname: 0.1.0
|
cross-dirname: 0.1.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
fs-extra: 11.4.0
|
fs-extra: 11.3.4
|
||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
postject: 1.0.0-alpha.6
|
postject: 1.0.0-alpha.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -6637,7 +6627,7 @@ snapshots:
|
|||||||
'@jridgewell/source-map@0.3.11':
|
'@jridgewell/source-map@0.3.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.30
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
@@ -6646,11 +6636,6 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
|
||||||
dependencies:
|
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
|
||||||
|
|
||||||
'@lezer/common@1.5.1': {}
|
'@lezer/common@1.5.1': {}
|
||||||
|
|
||||||
'@lezer/css@1.3.3':
|
'@lezer/css@1.3.3':
|
||||||
@@ -7756,7 +7741,7 @@ snapshots:
|
|||||||
mime-types: 3.0.1
|
mime-types: 3.0.1
|
||||||
negotiator: 1.0.0
|
negotiator: 1.0.0
|
||||||
|
|
||||||
acorn@8.18.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
adler-32@1.3.1: {}
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
@@ -8659,13 +8644,6 @@ snapshots:
|
|||||||
jsonfile: 6.2.0
|
jsonfile: 6.2.0
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
|
|
||||||
fs-extra@11.4.0:
|
|
||||||
dependencies:
|
|
||||||
graceful-fs: 4.2.11
|
|
||||||
jsonfile: 6.2.1
|
|
||||||
universalify: 2.0.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
fs-extra@7.0.1:
|
fs-extra@7.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -9047,13 +9025,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
jsonfile@6.2.1:
|
|
||||||
dependencies:
|
|
||||||
universalify: 2.0.1
|
|
||||||
optionalDependencies:
|
|
||||||
graceful-fs: 4.2.11
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
jwa@2.0.0:
|
jwa@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer-equal-constant-time: 1.0.1
|
buffer-equal-constant-time: 1.0.1
|
||||||
@@ -9541,7 +9512,7 @@ snapshots:
|
|||||||
|
|
||||||
react-refresh@0.18.0: {}
|
react-refresh@0.18.0: {}
|
||||||
|
|
||||||
react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
react-router@8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie-es: 3.1.1
|
cookie-es: 3.1.1
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
@@ -9957,7 +9928,7 @@ snapshots:
|
|||||||
terser@5.46.2:
|
terser@5.46.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.11
|
'@jridgewell/source-map': 0.3.11
|
||||||
acorn: 8.18.0
|
acorn: 8.16.0
|
||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user