* create report service

* write report from runtimeState

* use in UI

* clear report

* update types

* clear all from settings menu

* rearence rightclik menu

* refactor styling

* also report roll events

* ontime/under time is same colour

* refactor reporter

* add target to ontime-refetch

* remove menu

* fectch only on message from server

* memo useGetEventReport

* refactor

* use staleTime

* dont add to menu yet

* implement review

* clear all from menu

* fix merge

* start end show

* combine test for the go button text and action

* add report to menu

* extract csv utility

* report management

* unneeded async

* small refacort of triggerReportEntry

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2025-02-23 17:02:09 +01:00
committed by GitHub
parent 21877e4bff
commit b6507c27a6
35 changed files with 542 additions and 72 deletions
+1
View File
@@ -14,6 +14,7 @@ export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
+3 -2
View File
@@ -1,7 +1,8 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
+26
View File
@@ -0,0 +1,26 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`);
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { OntimeReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report';
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: fetchReport,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? {}, refetch };
}
@@ -0,0 +1,13 @@
import { makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSVFromArrayOfArrays(testdata)).toMatchInlineSnapshot(`
"field
after newline,after comma
,after empty
"
`);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
/**
* @description Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays);
}
+12 -8
View File
@@ -1,7 +1,7 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
import {
@@ -198,19 +198,23 @@ export const connectSocket = () => {
}
case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side
const { revision, reload } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
const { reload, target } = payload;
if (reload) {
invalidateAllCaches();
} else if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
} else if (target === 'RUNDOWN') {
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
} else if (target === 'REPORT') {
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
break;
}
case 'ontime-flush': {
flushBatchUpdates()
flushBatchUpdates();
break;
}
}
@@ -183,7 +183,7 @@ $inner-padding: 1rem;
color: $muted-gray;
td {
padding-block: 1rem;
padding-block: 5rem;
}
button {
@@ -67,9 +67,17 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
<tr className={style.empty}>
<td colSpan={99}>
<div>{label ?? 'No data yet'}</div>
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
New
</Button>
{handleClick && (
<Button
onClick={handleClick}
isDisabled={!handleClick}
variant='ontime-filled'
rightIcon={<IoAdd />}
size='sm'
>
New
</Button>
)}
</td>
</tr>
);
@@ -3,11 +3,13 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomFields from './custom-fields/CustomFields';
import ReportSettings from './ReportSettings';
import UrlPresetsForm from './UrlPresetsForm';
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
return (
<>
@@ -19,6 +21,10 @@ export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
<div ref={urlPresetsRef}>
<UrlPresetsForm />
</div>
<div ref={reportRef}>
<ReportSettings />
</div>
</>
);
}
@@ -0,0 +1,7 @@
th.over {
color: $ontime-delay-text;
}
th.under {
color: $playback-ahead;
}
@@ -0,0 +1,113 @@
import { useMemo } from 'react';
import { Button } from '@chakra-ui/react';
import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import style from './ReportSettings.module.scss';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
}
const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv');
};
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.rundown, data.order);
}, [reportData, data.rundown, data.order]);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button
variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
onClick={() => downloadCSV(combinedReport)}
isDisabled={combinedReport.length === 0}
>
Export CSV
</Button>
<Button
variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
color='#FA5656'
onClick={clearReport}
isDisabled={combinedReport.length === 0}
>
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty label='Reports are generated when running through the show.' />
)}
{combinedReport.map((entry) => {
const start = (() => {
if (entry.actualStart === null) return null;
if (entry.actualStart <= entry.scheduledStart) return 'under';
return 'over';
})();
const end = (() => {
if (entry.actualEnd === null) return null;
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
return 'over';
})();
return (
<tr key={entry.index}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,64 @@
import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
index: number;
title: string;
cue: string;
scheduledStart: number;
actualStart: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
};
/**
* Creates a combined report with the rundown data
*/
export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (order.length === 0) return [];
const combinedReport: CombinedReport[] = [];
for (const [key, value] of Object.entries(report)) {
if (!rundown[key] || !isOntimeEvent(rundown[key])) continue;
combinedReport.push({
index: order.findIndex((id) => id === key),
title: rundown[key].title,
cue: rundown[key].cue,
scheduledStart: rundown[key].timeStart,
actualEnd: value.endedAt,
scheduledEnd: rundown[key].timeEnd,
actualStart: value.startedAt,
});
}
return combinedReport;
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Transforms a CombinedReport into a CSV string
*/
export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv: string[][] = [];
csv.push(csvHeader);
for (const entry of combinedReport) {
csv.push([
String(entry.index),
entry.title,
entry.cue,
formatTime(entry.scheduledStart),
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
]);
}
return makeCSVFromArrayOfArrays(csv);
}
@@ -35,6 +35,7 @@ const staticOptions = [
secondary: [
{ id: 'feature_settings__custom', label: 'Custom fields' },
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
{ id: 'feature_settings__report', label: 'Report' },
],
},
{
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
@@ -34,7 +35,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disableGo = isRolling || noEvents;
const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst;
@@ -45,14 +46,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const disableStop = !playbackCan.stop;
const disableReload = !playbackCan.reload;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
const goModeAction = () => {
const [goModeAction, goModeText] = useMemo(() => {
if (isArmed) {
setPlayback.start();
} else {
setPlayback.startNext();
return [setPlayback.start, 'Start'];
} else if (isLast) {
return [setPlayback.stop, 'Finish'];
} else if (selectedEventIndex === null) {
return [setPlayback.startNext, 'Start'];
}
};
return [setPlayback.startNext, 'Next'];
}, [isArmed, isLast, selectedEventIndex]);
return (
<div className={style.buttonContainer}>
@@ -76,3 +76,10 @@
color: $ontime-roll;
font-size: $text-body-size;
}
.reportLink {
color: $label-gray;
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
}
@@ -1,9 +1,10 @@
import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { Playback, TimerPhase } from 'ontime-types';
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { useTimer } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { formatDuration } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
@@ -29,10 +30,6 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const { playback, children } = props;
const timer = useTimer();
const started = millisToString(timer.startedAt);
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
const finish = millisToString(expectedFinish);
const isRolling = playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
@@ -58,19 +55,55 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<>
<span className={style.start}>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</span>
</>
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
)}
</div>
{children}
</div>
);
}
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
playback: Playback;
}
function RunningStatus(props: RunningStatusProps) {
const { startedAt, expectedFinish, playback } = props;
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
return (
<>
<span className={style.start}>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</span>
</>
);
}
function StoppedStatus() {
const { data } = useReport();
const hasReport = Object.keys(data).length > 0;
if (hasReport) {
return (
<a className={style.reportLink} href='/editor?settings=feature_settings__report'>
Go to report management
</a>
);
}
return null;
}
@@ -46,7 +46,7 @@
}
.ahead {
color: $green-500;
color: $playback-ahead;
}
.behind {
@@ -22,7 +22,8 @@ export type EventItemActions =
| 'delete'
| 'clone'
| 'update'
| 'swap';
| 'swap'
| 'clear-report';
interface RundownEntryProps {
type: SupportedEvent;
@@ -175,7 +175,7 @@ export default function EventBlock(props: EventBlockProps) {
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
{ withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: false, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
],
);
@@ -125,6 +125,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
isLoaded={loaded}
totalGap={totalGap}
isLinkedAndNext={isNext && linkStart !== null}
duration={duration}
/>
)}
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
@@ -8,7 +8,7 @@
border-radius: 2px;
&.over {
color: $playback-negative;
color: $ontime-delay-text;
}
&.under {
@@ -1,9 +1,12 @@
import { useMemo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration } from '../../../../common/utils/time';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import style from './EventBlockChip.module.scss';
@@ -16,10 +19,11 @@ interface EventBlockChipProps {
className: string;
totalGap: number;
isLinkedAndNext: boolean;
duration: number;
}
export default function EventBlockChip(props: EventBlockChipProps) {
const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext } = props;
const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext, id, duration } = props;
const { playback } = usePlayback();
if (isLoaded) {
@@ -29,7 +33,7 @@ export default function EventBlockChip(props: EventBlockChipProps) {
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return null; //TODO: Event report will go here
return <EventReport className={className} id={id} duration={duration} />;
}
if (playbackActive) {
@@ -65,3 +69,55 @@ function EventUntil(props: EventUntilProps) {
return <div className={cx([style.chip, isDue && style.due])}>{timeUntilString}</div>;
}
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
return ['ontime', 'ontime', 'Event finished ontime'];
}
const isOver = difference > 0;
const fullTimeValue = formatTime(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
if (!value) {
return null;
}
return (
<Tooltip label={tooltip} openDelay={tooltipDelayFast}>
<div className={cx([style.chip, style[overUnderStyle], className])}>
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
</div>
</Tooltip>
);
}
@@ -1,6 +1,6 @@
import { ProjectData } from 'ontime-types';
import { makeCSV, makeTable, parseField } from '../cuesheet.utils';
import { makeTable, parseField } from '../cuesheet.utils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
@@ -114,15 +114,3 @@ describe('makeTable()', () => {
`);
});
});
describe('make CSV()', () => {
it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSV(testdata)).toMatchInlineSnapshot(`
"field
after newline,after comma
,after empty
"
`);
});
});
@@ -1,4 +1,3 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import {
CustomFields,
isOntimeDelay,
@@ -103,13 +102,3 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
return data;
};
/**
* @description Converts an array of arrays to a csv file
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export const makeCSV = (arrayOfArrays: string[][]): string => {
const stringifiedData = stringify(arrayOfArrays);
return stringifiedData;
};
@@ -234,6 +234,10 @@ function makeSettingsMenu(redirectWindow) {
label: 'URL presets',
click: () => redirectWindow('/editor?settings=feature_settings__urlpresets'),
},
{
label: 'Report',
click: () => redirectWindow('/editor?settings=feature_settings__report'),
},
],
},
{
+2
View File
@@ -11,6 +11,7 @@ import { router as sheetsRouter } from './sheets/sheets.router.js';
import { router as excelRouter } from './excel/excel.router.js';
import { router as sessionRouter } from './session/session.router.js';
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
import { router as reportRouter } from './report/report.router.js';
export const appRouter = express.Router();
@@ -25,6 +26,7 @@ appRouter.use('/excel', excelRouter);
appRouter.use('/url-presets', urlPresetsRouter);
appRouter.use('/session', sessionRouter);
appRouter.use('/view-settings', viewSettingsRouter);
appRouter.use('/report', reportRouter);
//we don't want to redirect to react index when using api routes
appRouter.all('/*', (_req, res) => {
@@ -0,0 +1,18 @@
import type { Request, Response } from 'express';
import type { OntimeReport } from 'ontime-types';
import * as report from './report.service.js';
export function getAll(_req: Request, res: Response<OntimeReport>) {
res.json(report.generate());
}
export function deleteAll(_req: Request, res: Response<OntimeReport>) {
report.clear();
res.status(200).send();
}
export function deleteWithId(req: Request, res: Response<OntimeReport>) {
const { eventId } = req.params;
report.clear(eventId);
res.status(200).send();
}
@@ -0,0 +1,10 @@
import express from 'express';
import { getAll, deleteWithId, deleteAll } from './report.controller.js';
import { paramsMustHaveEventId } from '../rundown/rundown.validation.js';
export const router = express.Router();
router.get('/', getAll);
router.delete('/all', deleteAll);
router.delete('/:eventId', paramsMustHaveEventId, deleteWithId);
@@ -0,0 +1,65 @@
import { OntimeReport, OntimeEventReport, TimerLifeCycle } from 'ontime-types';
import { RuntimeState } from '../../stores/runtimeState.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { DeepReadonly } from 'ts-essentials';
const report = new Map<string, OntimeEventReport>();
let formattedReport: OntimeReport | null = null;
/**
* generates a full report
* @returns full report
*/
export function generate(): OntimeReport {
if (formattedReport === null) {
formattedReport = Object.fromEntries(report);
}
return formattedReport;
}
/**
* clear report
* @param id optional id of a event report to clear
*/
export function clear(id?: string) {
formattedReport = null;
if (id) {
report.delete(id);
} else {
report.clear();
}
}
/**
* trigger report entry
* @param cycle
* @param state
* @returns
*/
export function triggerReportEntry(
cycle: TimerLifeCycle.onStart | TimerLifeCycle.onStop,
state: DeepReadonly<RuntimeState>,
) {
if (!state.eventNow?.id) {
return;
}
const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) {
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
formattedReport = null;
return;
}
if (cycle === TimerLifeCycle.onStop) {
const startedAt = report.get(eventId)?.startedAt ?? null;
report.set(eventId, { startedAt, endedAt: state.clock });
formattedReport = null;
sendRefetch({
target: 'REPORT',
});
return;
}
}
@@ -266,6 +266,7 @@ function notifyChanges(options: NotifyChangesOptions) {
if (options.external) {
// advice socket subscribers of change
const payload = {
target: 'RUNDOWN',
changes: Array.isArray(options.timer) ? options.timer : undefined,
reload: options.reload,
revision: cache.getMetadata().revision,
@@ -20,6 +20,8 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
import { timerConfig } from '../../config/config.js';
import { eventStore } from '../../stores/EventStore.js';
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { EventTimer } from '../EventTimer.js';
import { RestorePoint, restoreService } from '../RestoreService.js';
import {
@@ -288,14 +290,17 @@ class RuntimeService {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
return false;
}
const previousState = runtimeState.getState();
const rundown = getRundown();
const success = runtimeState.load(event, rundown, initialData);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
const newState = runtimeState.getState();
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onLoad, runtimeState.getState());
triggerReportEntry(TimerLifeCycle.onStop, previousState);
triggerAutomations(TimerLifeCycle.onLoad, newState);
});
}
return success;
@@ -474,6 +479,7 @@ class RuntimeService {
if (didStart) {
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStart, newState);
triggerAutomations(TimerLifeCycle.onStart, newState);
});
}
@@ -536,8 +542,8 @@ class RuntimeService {
*/
@broadcastResult
public stop(): boolean {
const state = runtimeState.getState();
const canStop = validatePlayback(state.timer.playback, state.timer.phase).stop;
const previousState = runtimeState.getState();
const canStop = validatePlayback(previousState.timer.playback, previousState.timer.phase).stop;
if (!canStop) {
return false;
}
@@ -546,6 +552,7 @@ class RuntimeService {
const newState = runtimeState.getState();
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
triggerAutomations(TimerLifeCycle.onStop, newState);
});
@@ -598,12 +605,14 @@ class RuntimeService {
if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
triggerAutomations(TimerLifeCycle.onLoad, newState);
});
}
if (result.didStart) {
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStart, newState);
triggerAutomations(TimerLifeCycle.onStart, newState);
});
}
-2
View File
@@ -391,7 +391,6 @@ export function start(state: RuntimeState = runtimeState): boolean {
// update offset
state.runtime.offset = getRuntimeOffset(state);
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
return true;
}
@@ -410,7 +409,6 @@ export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) {
return false;
}
clear();
runtimeState.runtime.actualStart = null;
runtimeState.runtime.expectedEnd = null;
@@ -0,0 +1,8 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
export type OntimeEventReport = {
startedAt: MaybeNumber;
endedAt: MaybeNumber;
};
export type OntimeReport = Record<string, OntimeEventReport>;
+3
View File
@@ -16,6 +16,9 @@ export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from '.
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
export { TimerType } from './definitions/TimerType.type.js';
// ---> Report
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
// ---> Automations
export type {
Automation,