Compare commits

...

7 Commits

Author SHA1 Message Date
Claude 4e2baf1fa1 feat(network): add session status card
Display server health metrics from the unused /session endpoint:
- Connected clients count (live from useClientStore)
- Server uptime since last restart
- Last client connection time (relative)
- Last integration request time (relative)
- Server timezone
- Cloud latency (Docker only) with warning threshold

Adds useSessionStats hook with 60s refetch interval, new NetworkStatus
component as first card in Network panel, and navigation menu entries
for Server status and Network interfaces sections.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 18:28:15 +00:00
Claude 46e4ac94f1 feat(log): add filters to the event log and cap the log store
Finding a single error in a stream of RX/TX traffic was not practical
with only the origin filters.

- add a text filter and an issues only filter
- show how many entries are being displayed
- add an empty state which distinguishes no activity from no matches
- collapse the six origin flags into a single set
- cap the log store at 500 entries, it was growing unbounded for the
  duration of a session
- the event log button was labelled Extract, which did not convey that
  it opens the log in a new window

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:20:46 +00:00
Claude b5e1bccc70 feat(network): help users orient themselves in the network panel
- add a network interfaces card to the network panel. The addresses
  users need to reach Ontime from other devices were only available
  in the sharing panel
- show the port along with the address, the bare IP is not enough to
  reach the server
- explain what each section does and how the client actions behave
- the client path was selectable text but could not be opened or
  copied. It now uses a copy tag with the full client URL, which also
  makes the client origin visible in setups with several interfaces

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:18:52 +00:00
Claude c840add9c5 fix(network): correct defects in client management UI
- self row highlight lost a specificity fight with the table zebra
  striping, so the current client was only highlighted on odd rows.
  Reuse the data attribute pattern already used for data-warn
- drop reference to a `blink` class which was never defined, the button
  variant already communicates the identify state
- use `th` in table headers so the shared table header styling applies,
  matching the other settings panels
- add empty states to both client tables
- keep a stable row order (self first, then by name) so rows do not
  move as clients connect and disconnect
- the redirect dialog disabled its view select when no URL presets
  existed, making it impossible to redirect to a plain view. Its
  default value also disagreed with component state, leaving the
  button disabled until the visible value was re-picked
- rename dialog now autofocuses and submits on Enter
- move the cloud ping into a card, matching the panel conventions, and
  flag high latency values

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:15:43 +00:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
Alex Christoffer Rasmussen 2a890cf2b3 fix: issue where a countToEnd would lead to incorrect expected times (#2149)
* fix: issue where a count-to-end would lead to incorrect expected times

* fix: include add time in overtime when countToEnd

* fix: ui and server use same calculation for expected end
2026-07-25 11:22:24 +02:00
36 changed files with 1031 additions and 250 deletions
+1
View File
@@ -15,6 +15,7 @@ export const RUNDOWN = ['rundown'];
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
export const RUNTIME = ['runtimeStore'];
export const SESSION_STATS = ['sessionStats'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
+9 -1
View File
@@ -1,11 +1,19 @@
import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { GetInfo, LinkOptions, SessionStats } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve statistics of the running session
*/
export async function getSessionStats(options?: RequestOptions): Promise<SessionStats> {
const res = await axios.get(sessionPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to retrieve application info
*/
@@ -13,6 +13,9 @@ import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
/** the first navigator view, used as the initial value of the view select */
const defaultView = `/${navigatorConstants[0].url}`;
interface RedirectClientModalProps {
id: string;
name: string;
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const [selected, setSelected] = useState(defaultView);
const { setRedirect } = setClientRemote;
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
defaultValue={defaultView}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
disabled={selected === currentPath}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
@@ -2,6 +2,7 @@ import { useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import { setClientRemote } from '../../hooks/useSocket';
import { isKeyEnter } from '../../utils/keyEvent';
import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input';
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
showCloseButton
onClose={onClose}
bodyElements={
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
<Input
height='large'
placeholder='New name'
autoFocus
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (isKeyEnter(event) && canSubmit) {
handleRename();
}
}}
/>
}
footerElements={
<>
@@ -20,7 +20,7 @@
.wide {
top: 4vh;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
max-width: min(1800px, 98vw);
height: 88vh;
max-height: 88vh;
display: flex;
@@ -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 };
}
+4 -1
View File
@@ -15,11 +15,14 @@ const logger = createStore<LogStore>(() => ({
logs: [],
}));
/** the log is kept in memory, we cap it to avoid it growing through a long show */
const maxLogEntries = 500;
export const useLogData = () => useStore(logger);
export const addLog = (log: Log) =>
logger.setState((state) => ({
logs: [log, ...state.logs],
logs: [log, ...state.logs].slice(0, maxLogEntries),
}));
export const clearLogs = () => logger.setState({ logs: [] });
+3 -4
View File
@@ -5,6 +5,7 @@ import {
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -186,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
...state,
},
);
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
expectedEnd,
plannedEnd,
};
}
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
td[data-warn='true'] {
background-color: $orange-1300;
}
tr[data-highlight='true'] {
background-color: $blue-1100;
}
}
.listGroup {
@@ -25,6 +25,13 @@
margin-top: 1rem;
}
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -8,6 +8,7 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import {
getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -56,9 +57,11 @@ export default function SourcesPanel() {
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
}
};
const handleFinished = () => {
const handleFinished = (rundownTitle: string) => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (
@@ -139,7 +144,7 @@ export default function SourcesPanel() {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished();
handleFinished(title);
return;
}
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
customFields: preview.customFields,
providedFields,
});
handleFinished();
handleFinished(currentRundown.title);
};
const loadWorksheetMetadata = useCallback(
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>Spreadsheet data applied.</div>
<div className={style.finishDescription}>You can close this flow or start another import.</div>
<Button variant='subtle-white' onClick={resetFlow}>
Reset flow
</Button>
<div className={style.finishTitle}>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
</div>
<div className={style.finishDescription}>
Review the imported rundown in the editor or start another import.
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
</div>
)}
{isGSheetFlow && (
@@ -92,6 +92,10 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
{toolbarStatus && (
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<span className={style.previewPaneTitle}>Import preview</span>
<div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</div>
<div className={style.tableShell}>
<PreviewTable
preview={state.preview}
columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders}
/>
</div>
@@ -1,27 +1,35 @@
.emptyState {
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
padding: 3rem 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: $gray-400;
font-size: 0.95rem;
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
}
.table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -34,31 +42,101 @@
}
th {
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal;
}
th,
td {
padding: 0.5rem;
box-sizing: border-box;
min-width: 8rem;
vertical-align: top;
max-width: 20rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
tr:nth-child(even) {
background-color: $white-1;
tbody tr {
--entry-colour: transparent;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
width: 4.5rem;
min-width: 4.5rem;
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
}
.rowType {
width: 7rem;
min-width: 7rem;
left: 3.25rem;
width: 6.25rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
}
@@ -1,7 +1,11 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null;
columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[];
}
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({
preview,
columnLabels,
canRefresh,
isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders,
}: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => {
@@ -24,33 +99,57 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
}
return (
<div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{emptyTitle}</div>
<div className={style.emptyBody}>{emptyContent}</div>
{needsPreviewRefresh && (
<Button
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
Refresh preview
</Button>
)}
</div>
</div>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => (
{displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -59,16 +158,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return (
<tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
{columnLabels.map((label, colIndex) => (
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
))}
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>
<Tag>{entryType}</Tag>
</td>
{displayColumns.map((label, colIndex) => {
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
</tr>
);
})}
@@ -5,7 +5,8 @@ import type {
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -32,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -40,12 +41,14 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export';
error: string;
preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
};
const initialImportState: ImportState = {
loading: '',
error: '',
preview: null,
needsPreviewRefresh: false,
};
function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview };
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null };
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null };
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
}
return { ...state, loading: '', error: action.error };
}
@@ -221,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview' });
dispatch({ type: 'clearPreview', needsRefresh: true });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -297,15 +300,37 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return `entries: | start: | end: | duration: ${warningText}`;
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
}
const { flatOrder } = state.preview.rundown;
const { entries, flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
let groups = 0;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
})();
return {
@@ -19,10 +19,12 @@ export default function InfoNif() {
// interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
// we show the address as it would be typed in a browser, including the port
const label = new URL(address).host;
return (
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
<CopyTag key={`${nif.name}-${nif.address}`} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${label}`} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
@@ -19,10 +19,13 @@ export default function LogExport() {
<Panel.SubHeader>
Event log
<Button onClick={extract}>
Extract <IoArrowUp className={style.iconRotate} />
Open in new window <IoArrowUp className={style.iconRotate} />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Activity in this session. The log is not saved with the project and is cleared when Ontime restarts.
</Panel.Paragraph>
<Log />
</Panel.Card>
</Panel.Section>
@@ -1,26 +1,38 @@
import { MessageTag } from 'ontime-types';
import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { sendSocket } from '../../../../common/utils/socket';
import { isDocker } from '../../../../externals';
import { isOntimeCloud } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from './client-control/ClientControlPanel';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport';
import NetworkStatus from './NetworkStatus';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const statusRef = useScrollIntoView<HTMLDivElement>('status', location);
const interfacesRef = useScrollIntoView<HTMLDivElement>('interfaces', location);
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
return (
<>
<Panel.Header>Network</Panel.Header>
{isDocker && (
<Panel.Section>
<OntimeCloudStats />
</Panel.Section>
<div ref={statusRef}>
<NetworkStatus />
</div>
{!isOntimeCloud && (
<div ref={interfacesRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Network interfaces</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Addresses which can be used to reach Ontime from other devices in the network. <br />
Click an address to open it in a new window, or use the icon to copy it to the clipboard.
</Panel.Paragraph>
<InfoNif />
</Panel.Card>
</Panel.Section>
</div>
)}
<div ref={logRef}>
<LogExport />
@@ -31,29 +43,3 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
</>
);
}
function OntimeCloudStats() {
const ping = usePing();
/**
* Send immediate ping request, and keep sending on an interval
*/
useEffect(() => {
sendSocket(MessageTag.Ping, new Date());
const doPing = setInterval(() => {
sendSocket(MessageTag.Ping, new Date());
}, 5000);
return () => {
clearInterval(doPing);
};
}, []);
return (
<Panel.SubHeader>
Ontime cloud
<Panel.Description>Current ping: {ping}ms</Panel.Description>
</Panel.SubHeader>
);
}
@@ -0,0 +1,109 @@
import { MaybeString, MessageTag } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { useEffect } from 'react';
import Tag from '../../../../common/components/tag/Tag';
import useSessionStats from '../../../../common/hooks-query/useSessionStats';
import { usePing } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore';
import { formatDuration } from '../../../../common/utils/time';
import { sendSocket } from '../../../../common/utils/socket';
import { isDocker } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
/** ping values above this are flagged to the user (ms) */
const slowPingThreshold = 100;
/** how often we ask the server for a ping (ms) */
const pingInterval = 5000;
/**
* Presents a date as the time elapsed since it happened
*/
function timeSince(date: MaybeString): string {
if (date === null) {
return 'Never';
}
const elapsed = Date.now() - new Date(date).getTime();
if (elapsed < MILLIS_PER_MINUTE) {
return 'Just now';
}
return `${formatDuration(elapsed)} ago`;
}
export default function NetworkStatus() {
const { data, status, isError } = useSessionStats();
// the client list is kept up to date over websocket, unlike the polled session stats
const clients = useClientStore((store) => store.clients);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Server status</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{isError && <Panel.Error>Failed to load session data</Panel.Error>}
<Panel.Divider />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Connected clients' description='Clients currently connected to this server' />
<Tag>{Object.keys(clients).length}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Server running for' description='Time since the Ontime server was started' />
<Tag>{data ? formatDuration(Date.now() - new Date(data.startedAt).getTime()) : '...'}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Last client connection' description='When a client last connected to the server' />
<Tag>{data ? timeSince(data.lastConnection) : '...'}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Last integration request'
description='When the server last received a request from an integration'
/>
<Tag>{data ? timeSince(data.lastRequest) : '...'}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Server time zone' description='Time zone used by the machine running Ontime' />
<Tag>{data ? data.timezone : '...'}</Tag>
</Panel.ListItem>
{isDocker && <CloudPing />}
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
);
}
/**
* Ontime cloud users are not in the same network as the server
* we show the round trip time to help them qualify the connection
*/
function CloudPing() {
const ping = usePing();
/**
* Send immediate ping request, and keep sending on an interval
*/
useEffect(() => {
sendSocket(MessageTag.Ping, new Date());
const doPing = setInterval(() => {
sendSocket(MessageTag.Ping, new Date());
}, pingInterval);
return () => {
clearInterval(doPing);
};
}, []);
return (
<Panel.ListItem>
<Panel.Field
title='Ontime cloud latency'
description='Time for a message to travel to the server and back. Lower is better'
/>
<Tag variant={ping > slowPingThreshold ? 'warning' : 'default'}>{ping}ms</Tag>
</Panel.ListItem>
);
}
@@ -1,11 +1,3 @@
.fullWidth {
width: 100%;
}
.halfWidth {
width: 50%;
}
.halfWidthNoWrap {
width: 50%;
white-space: nowrap;
@@ -16,6 +8,11 @@
user-select: text;
}
.self {
background-color: $blue-1100;
.muted {
color: $label-gray;
}
// align section notes with the table contents
.note {
padding: 0 2rem;
}
@@ -5,14 +5,42 @@ import { useState } from 'react';
import Button from '../../../../../common/components/buttons/Button';
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Tag from '../../../../../common/components/tag/Tag';
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore';
import { cx } from '../../../../../common/utils/styleUtils';
import { openLink } from '../../../../../common/utils/linkUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss';
/**
* Shows the path a client is showing
* The full URL is available for copying or opening in a new window
*/
function ClientPath({ origin, path }: { origin: string; path: string }) {
// clients report their path on connection, we may not have it yet
if (!path) {
return <span className={style.muted}>unknown</span>;
}
// origin is empty for clients which have not reported their location
if (!origin) {
return <span className={style.copiable}>{path}</span>;
}
const fullUrl = `${origin}${path}`;
return (
<Tooltip text={fullUrl} render={<span />}>
<CopyTag copyValue={fullUrl} size='small' onClick={() => openLink(fullUrl)}>
{path}
</CopyTag>
</Tooltip>
);
}
export default function ClientList() {
const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients);
@@ -32,8 +60,19 @@ export default function ClientList() {
redirectHandler.open();
};
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
/**
* Clients are given by the server in connection order, which means rows move
* as clients come and go. We keep a stable order: self first, then by name
*/
const sortClients = (clientEntries: [string, Client][]) =>
clientEntries.sort(([keyA, clientA], [keyB, clientB]) => {
if (keyA === id) return -1;
if (keyB === id) return 1;
return clientA.name.localeCompare(clientB.name);
});
const ontimeClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type === 'ontime'));
const otherClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type !== 'ontime'));
const targetClient: Client | undefined = clients[targetId];
@@ -59,29 +98,37 @@ export default function ClientList() {
)}
<Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
<div className={style.note}>
<Panel.Description>
Ontime views connected to this server. Identify shows a marker in the client screen until you turn it off,
redirect sends the client to a different view.
</Panel.Description>
</div>
<Panel.Table>
<thead>
<tr>
<td style={{ width: '20%' }}>Client Name</td>
<td>Path</td>
<td />
<th style={{ width: '20%' }}>Client Name</th>
<th>Path</th>
<th />
</tr>
</thead>
<tbody>
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key} className={cx([isCurrent && style.self])}>
<tr key={key} data-highlight={isCurrent}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td className={style.copiable}>{path}</td>
<td>
<ClientPath origin={client.origin} path={path} />
</td>
<Panel.InlineElements relation='inner' as='td'>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
@@ -117,14 +164,21 @@ export default function ClientList() {
<Panel.Divider />
<Panel.Section>
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
<div className={style.note}>
<Panel.Description>
Connections which are not Ontime views, such as integrations and custom clients. These cannot be controlled
from Ontime.
</Panel.Description>
</div>
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidthNoWrap}>Client Name</td>
<td className={style.halfWidthNoWrap}>Client type</td>
<th className={style.halfWidthNoWrap}>Client Name</th>
<th className={style.halfWidthNoWrap}>Client type</th>
</tr>
</thead>
<tbody>
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
{otherClients.map(([key, client]) => {
const { name, type } = client;
@@ -67,6 +67,14 @@ const staticOptions = [
id: 'network',
label: 'Network',
secondary: [
{
id: 'network__status',
label: 'Server status',
},
{
id: 'network__interfaces',
label: 'Network interfaces',
},
{
id: 'network__log',
label: 'Event log',
@@ -51,3 +51,13 @@ $info-hover: $section-white;
flex-wrap: wrap;
padding-bottom: 0.5rem;
}
.count {
color: $info-gray;
white-space: nowrap;
}
.empty {
color: $info-gray;
padding: 0.5rem 0;
}
+64 -86
View File
@@ -1,115 +1,90 @@
import { LogOrigin } from 'ontime-types';
import { LogLevel, LogOrigin } from 'ontime-types';
import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import Input from '../../common/components/input/input/Input';
import { clearLogs, useLogData } from '../../common/stores/logger';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss';
const allOrigins = [
LogOrigin.User,
LogOrigin.Client,
LogOrigin.Server,
LogOrigin.Playback,
LogOrigin.Rx,
LogOrigin.Tx,
];
export default function Log() {
const { logs: logData } = useLogData();
const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true);
const [showRx, setShowRx] = useState(true);
const [showTx, setShowTx] = useState(true);
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
// log entries are not guaranteed to have an origin from the enum
const [origins, setOrigins] = useState<Set<string>>(() => new Set<string>(allOrigins));
const [onlyProblems, setOnlyProblems] = useState(false);
const [search, setSearch] = useState('');
const matchers: LogOrigin[] = [];
if (showUser) {
matchers.push(LogOrigin.User);
}
if (showClient) {
matchers.push(LogOrigin.Client);
}
if (showServer) {
matchers.push(LogOrigin.Server);
}
if (showRx) {
matchers.push(LogOrigin.Rx);
}
if (showTx) {
matchers.push(LogOrigin.Tx);
}
if (showPlayback) {
matchers.push(LogOrigin.Playback);
}
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
const disableOthers = useCallback((toEnable: LogOrigin) => {
setShowUser(toEnable === LogOrigin.User);
setShowClient(toEnable === LogOrigin.Client);
setShowServer(toEnable === LogOrigin.Server);
setShowRx(toEnable === LogOrigin.Rx);
setShowTx(toEnable === LogOrigin.Tx);
setShowPlayback(toEnable === LogOrigin.Playback);
const toggleOrigin = useCallback((origin: LogOrigin) => {
setOrigins((previous) => {
const newOrigins = new Set(previous);
if (newOrigins.has(origin)) {
newOrigins.delete(origin);
} else {
newOrigins.add(origin);
}
return newOrigins;
});
}, []);
/** middle click on an origin shows only that origin */
const showOnlyOrigin = useCallback((origin: LogOrigin) => {
setOrigins(new Set([origin]));
}, []);
const searchTerm = search.toLowerCase();
const filteredData = logData.filter((entry) => {
if (!origins.has(entry.origin)) return false;
if (onlyProblems && entry.level === LogLevel.Info) return false;
if (searchTerm && !entry.text.toLowerCase().includes(searchTerm)) return false;
return true;
});
const isFiltered = filteredData.length !== logData.length;
return (
<>
<Panel.InlineElements className={style.buttonBar}>
{allOrigins.map((origin) => (
<Button
key={origin}
variant={origins.has(origin) ? 'primary' : 'subtle'}
size='small'
onClick={() => toggleOrigin(origin)}
onAuxClick={() => showOnlyOrigin(origin)}
onContextMenu={(event) => event.preventDefault()}
>
{origin}
</Button>
))}
<Button
variant={showUser ? 'primary' : 'subtle'}
variant={onlyProblems ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
onClick={() => setOnlyProblems((previous) => !previous)}
>
{LogOrigin.User}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Client}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Server}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Playback}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Rx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
Issues only
</Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
</Panel.InlineElements>
<Panel.InlineElements className={style.buttonBar}>
<Input fluid placeholder='Filter messages' value={search} onChange={(event) => setSearch(event.target.value)} />
<span className={style.count}>
{isFiltered ? `${filteredData.length} of ${logData.length}` : logData.length} entries
</span>
</Panel.InlineElements>
<ul className={style.log}>
{filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
@@ -118,6 +93,9 @@ export default function Log() {
<span className={style.msg}>{logEntry.text}</span>
</li>
))}
{filteredData.length === 0 && (
<li className={style.empty}>{logData.length === 0 ? 'No activity yet' : 'No entries match the filters'}</li>
)}
</ul>
</>
);
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
//TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return (
@@ -1,17 +1,15 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode,
});
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
// while a group is live, surface the running event's title as the secondary line
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
}
interface SubscriptionStatusProps {
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
event: CountdownEvent;
}
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
@@ -12,7 +12,7 @@ import {
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
@@ -271,6 +271,7 @@ export function extendEventData(
offset,
mode,
});
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
return { ...event, expectedStart, endedAt, expectedEnd };
}
@@ -1126,7 +1126,7 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000); // <--- offset is always the overtime
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
});
it('handles time-to-end started after the end time', () => {
+3 -3
View File
@@ -180,13 +180,13 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
// in case of count to end, the absolute offset is overtime and added time
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
// in case of count to end, the absolute offset is just the overtime
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
return { absolute, relative };
}
/**
@@ -1036,4 +1036,167 @@ describe('loadGroupFlagAndEnd()', () => {
eventNow: rundown.entries[0],
});
});
test('countToEnd entries decouple the link chain for subsequent events', () => {
// Event 0 (loaded): no countToEnd, linkStart=true
// Event 1: countToEnd=true → breaks the link chain
// Event 2: linkStart=true → unlinked (chain broken by event 1)
// Event 3: flag event → also unlinked
const rundown = makeRundown({
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
0: makeOntimeEvent({
id: '0',
parent: 'group',
timeStart: 0,
duration: 3600000,
countToEnd: false,
linkStart: true,
gap: 0,
} as any),
1: makeOntimeEvent({
id: '1',
parent: 'group',
timeStart: 3600000,
duration: 3600000,
countToEnd: true,
linkStart: true,
gap: 0,
} as any),
2: makeOntimeEvent({
id: '2',
parent: 'group',
timeStart: 7200000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
3: makeOntimeEvent({
id: '3',
parent: null,
timeStart: 10800000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
},
order: ['group', '0', '1', '2', '3'],
});
const state = {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// _group is the last event in the group (event 2)
// isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true
// accumulatedGap includes event 1's duration carried forward to event 2
// (the countToEnd duration is applied to the next event, not the countToEnd event itself)
expect(state._group).toMatchObject({
event: rundown.entries[2],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1
// accumulatedGap is still 3600000 because event 2 is not countToEnd,
// so previousWasCountToEnd is null and no further duration is carried forward
expect(state._flag).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _end (event 3): also unlinked because the chain was broken at event 1
expect(state._end).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
});
});
test('countToEnd in the middle of the chain breaks links for downstream events', () => {
// Event 0 (loaded): no countToEnd, linkStart=true
// Event 1: linkStart=true → linked to loaded
// Event 2: countToEnd=true → breaks the chain (also last in group)
// Event 3: linkStart=true → unlinked (chain broken by event 2)
const rundown = makeRundown({
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
0: makeOntimeEvent({
id: '0',
parent: 'group',
timeStart: 0,
duration: 3600000,
countToEnd: false,
linkStart: true,
gap: 0,
} as any),
1: makeOntimeEvent({
id: '1',
parent: 'group',
timeStart: 3600000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
2: makeOntimeEvent({
id: '2',
parent: 'group',
timeStart: 7200000,
duration: 3600000,
countToEnd: true,
linkStart: true,
gap: 0,
} as any),
3: makeOntimeEvent({
id: '3',
parent: null,
timeStart: 10800000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
},
order: ['group', '0', '1', '2', '3'],
});
const state = {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// _group is the last event in the group (event 2)
// isLinkedToLoaded is true because no preceding event had countToEnd
// accumulatedGap is 0 because no preceding event was countToEnd
// (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself)
expect(state._group).toMatchObject({
event: rundown.entries[2],
isLinkedToLoaded: true,
accumulatedGap: 0,
});
// _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2
// accumulatedGap includes event 2's duration carried forward
expect(state._flag).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _end (event 3): unlinked because event 2 has countToEnd=true
expect(state._end).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
});
});
});
+21 -5
View File
@@ -23,6 +23,7 @@ import {
calculateDuration,
checkIsNow,
dayInMs,
getExpectedEnd,
getExpectedStart,
getLastEventNormal,
isPlaybackActive,
@@ -831,7 +832,6 @@ function getExpectedTimes(state = runtimeState) {
state.offset.expectedRundownEnd = null;
state.offset.expectedGroupEnd = null;
state.offset.expectedFlagStart = null;
state.offset.expectedRundownEnd = null;
const { offset } = state;
const { plannedStart, actualStart } = state.rundown;
@@ -852,7 +852,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
}
}
@@ -884,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = expectedStart + event.duration;
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
}
}
@@ -927,6 +927,7 @@ export function loadGroupFlagAndEnd(
let accumulatedGap = 0;
let isLinkedToLoaded = true;
let previousWasCountToEnd: Maybe<number> = null;
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
const entry = entries[playableEventOrder[idx]];
@@ -934,8 +935,23 @@ export function loadGroupFlagAndEnd(
if (isOntimeEvent(entry)) {
if (idx !== currentIndex) {
// we only accumulate data after the loaded event
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
if (previousWasCountToEnd !== null) {
/** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream)
* and break the link to the loaded event since countToEnd events reset the schedule
*/
accumulatedGap += entry.gap + previousWasCountToEnd;
isLinkedToLoaded = false;
} else {
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
}
if (entry.countToEnd) {
previousWasCountToEnd = entry.duration;
} else {
previousWasCountToEnd = null;
}
// and the loaded event is not allowed to be the next flag
if (!foundFlag && metadata.flags.includes(entry.id)) {
@@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
await page.getByRole('button', { name: 'Preview import' }).click();
await page.getByRole('button', { name: 'Apply import' }).click();
await expect(page.getByText('Import complete')).toBeVisible();
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
await page.getByRole('button', { name: 'Reset flow' }).click();
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
await page.getByRole('button', { name: 'Import another' }).click();
// verify the data in the rundown
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
+1 -1
View File
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
// feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
@@ -1,7 +1,7 @@
import { Day, OffsetMode } from 'ontime-types';
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
import { getExpectedStart } from './getExpectedStart';
import { getExpectedEnd, getExpectedStart } from './getExpected';
describe('getExpectedStart()', () => {
describe('Absolute offset mode', () => {
@@ -315,3 +315,144 @@ describe('getExpectedStart()', () => {
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
});
});
describe('getExpectedEnd()', () => {
const baseState = {
currentDay: 0,
totalGap: 0,
mode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
isLinkedToLoaded: true,
};
test('a regular event ends at its expected start plus duration', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: false,
};
// on schedule
const expectedStart0 = getExpectedStart(testEvent, { ...baseState, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart0, baseState.currentDay)).toBe(150);
// running 20 behind pushes the end out
const expectedStart20 = getExpectedStart(testEvent, { ...baseState, offset: 20 });
expect(getExpectedEnd(testEvent, expectedStart20, baseState.currentDay)).toBe(170);
});
test('a countToEnd event pins to the planned end while in overtime', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 20 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150);
});
test('an overnight countToEnd event returns a normalised end', () => {
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
const timeStart = 23 * MILLIS_PER_HOUR;
const duration = 2 * MILLIS_PER_HOUR;
const testEvent = {
timeStart,
duration,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(timeStart + duration);
});
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 20,
dayOffset: 0 as Day,
};
// events shift their schedule based on delay...
const expectedStart = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStart, baseState.currentDay)).toBe(170);
// ... but count to end events stay pinned to the scheduled end
const expectedStartCountToEnd = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, expectedStartCountToEnd, baseState.currentDay)).toBe(150);
});
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 60,
dayOffset: 0 as Day,
countToEnd: true,
};
// the delayed start (160) is past the fixed end (150), so the event can no longer
// finish on time and the end follows the compromised start
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(160);
});
test('a countToEnd event on a later day keeps the day offset on the end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 1 as Day,
countToEnd: true,
};
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
// not collapse to the day-shifted start
const expectedStart = getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150 + dayInMs);
// when the running event is already on the same day, no extra day is added
const expectedStartSameDay = getExpectedStart(
{ ...testEvent, dayOffset: 0 as Day },
{ ...baseState, currentDay: 0, offset: 0 },
);
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, expectedStartSameDay, baseState.currentDay)).toBe(150);
});
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
const relativeState = {
...baseState,
mode: OffsetMode.Relative,
actualStart: 30,
plannedStart: 0,
offset: 0,
};
// a regular event in the same state is shifted by the relative-start offset to 180
const expectedStartRegular = getExpectedStart({ ...testEvent }, relativeState);
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStartRegular, relativeState.currentDay)).toBe(
180,
);
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
const expectedStartCountToEnd = getExpectedStart(testEvent, relativeState);
expect(getExpectedEnd(testEvent, expectedStartCountToEnd, relativeState.currentDay)).toBe(150);
});
});
@@ -3,15 +3,6 @@ import { OffsetMode } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* @param event the event that we are counting to
* @param currentDay the day offset of the currently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
*/
export function getExpectedStart(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
state: {
@@ -60,3 +51,25 @@ export function getExpectedStart(
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
return offsetStartTimeBufferedByGaps;
}
export function getExpectedEnd(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'duration' | 'countToEnd'>,
expectedStart: number,
currentRuntimeDay: number,
): number {
/**
* Count to end events are a special case
* - the end time is always the wall clock
*/
if (event.countToEnd) {
// account for day offset
const relativeDayOffset = event.dayOffset - currentRuntimeDay;
const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs;
// count to end should finish on the planned time or on start
return Math.max(expectedStart, plannedEnd);
}
// for normal events, the expected end is when we would start + its duration
return expectedStart + event.duration;
}