mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 19:33:46 +00:00
feat(report): persistent show run history
Turns the reporter from a live-only, single-run curiosity into a persistent, per-project, per-rundown history of runs. - Extend OntimeEventReport with a schedule snapshot (scheduledStart, scheduledDuration) taken when an event starts, plus playCount. Reports are now a record that survives later rundown edits, rather than a live join against the current rundown. - Add a run lifecycle: a run opens on the first event start and closes on a full stop, archiving the previous run to history so the next start begins fresh. - Persist runs to a per-project sidecar file (services/report-service), patterned on the existing restore-service, so a crash or restart no longer loses the whole show's record. Runs are scoped by rundownId for multi-rundown projects, cascade-deleted with their rundown or project, renamed alongside a project rename, and deliberately not copied when a project is duplicated. - Extract the over/under/on-time variance and run summary maths shared by the rundown chip, the report settings panel, and the server into ontime-utils (getEventVariance, getRunSummary, countPlannedEvents). - Extend the report API with run history endpoints (list, get, latest, rename, delete) while keeping GET /report's existing shape so Companion and HTTP automations are unaffected. - Replace the settings report table with a run browser: a list of runs with a rundown filter, inline rename, delete, and a detail view with per-run summary stats and CSV export. - Add a third, muted state to the rundown event chip: an event with nothing in the current run yet previews how it went last time. Fixes a real bug found while testing the new store: emptyStore() was a shared object, so its runs array leaked mutations across project loads. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import type { PlayableEvent, ShowRun } from 'ontime-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
|
||||
// in-memory stand-in for the sidecar store, verified separately in report.store.test.ts
|
||||
let runs: ShowRun[] = [];
|
||||
|
||||
vi.mock('../../../services/report-service/report.store.js', () => ({
|
||||
// isolation between tests comes from the top-level beforeEach resetting `runs`,
|
||||
// this mirrors the real store returning whatever is already on "disk"
|
||||
loadReports: vi.fn(async () => ({ runs })),
|
||||
getRuns: vi.fn(() => runs),
|
||||
getRun: vi.fn((id: string) => runs.find((run) => run.id === id)),
|
||||
upsertRun: vi.fn(async (run: ShowRun) => {
|
||||
const index = runs.findIndex((candidate) => candidate.id === run.id);
|
||||
if (index === -1) {
|
||||
runs.unshift(run);
|
||||
} else {
|
||||
runs[index] = run;
|
||||
}
|
||||
}),
|
||||
deleteRun: vi.fn(async (id: string) => {
|
||||
const index = runs.findIndex((run) => run.id === id);
|
||||
if (index === -1) return false;
|
||||
runs.splice(index, 1);
|
||||
return true;
|
||||
}),
|
||||
deleteRunsForRundown: vi.fn(async (rundownId: string) => {
|
||||
const before = runs.length;
|
||||
runs = runs.filter((run) => run.rundownId !== rundownId);
|
||||
return before - runs.length;
|
||||
}),
|
||||
deleteAllRuns: vi.fn(async () => {
|
||||
runs = [];
|
||||
}),
|
||||
}));
|
||||
|
||||
let currentRundown = makeRundown({ id: 'rundown-1', title: 'Test rundown' });
|
||||
|
||||
vi.mock('../../rundown/rundown.dao.js', () => ({
|
||||
getCurrentRundown: vi.fn(() => currentRundown),
|
||||
}));
|
||||
|
||||
const {
|
||||
generate,
|
||||
clear,
|
||||
triggerReportEntry,
|
||||
closeRun,
|
||||
initReports,
|
||||
listRuns,
|
||||
getRun,
|
||||
getLatestRun,
|
||||
renameRun,
|
||||
deleteRun,
|
||||
deleteAllRuns,
|
||||
} = await import('../report.service.js');
|
||||
|
||||
const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent;
|
||||
const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000, duration: 10000 }) as PlayableEvent;
|
||||
|
||||
beforeEach(async () => {
|
||||
runs = [];
|
||||
currentRundown = makeRundown({
|
||||
id: 'rundown-1',
|
||||
title: 'Test rundown',
|
||||
entries: { [eventA.id]: eventA, [eventB.id]: eventB },
|
||||
order: [eventA.id, eventB.id],
|
||||
flatOrder: [eventA.id, eventB.id],
|
||||
});
|
||||
await initReports('project-a');
|
||||
});
|
||||
|
||||
describe('triggerReportEntry()', () => {
|
||||
it('captures a snapshot of the schedule when an event starts', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
|
||||
expect(generate()).toMatchObject({
|
||||
[eventA.id]: {
|
||||
startedAt: 500,
|
||||
endedAt: null,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDuration: eventA.duration,
|
||||
playCount: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('records the end time on stop, keeping the snapshot taken at start', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: 0,
|
||||
endedAt: 12000,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('increments playCount when an event is re-run within the same show', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 5000 } as typeof state);
|
||||
triggerReportEntry(TimerLifeCycle.onStart, { ...state, clock: 5000 } as typeof state);
|
||||
|
||||
expect(generate()[eventA.id].playCount).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores events without an id', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: null });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
|
||||
it('persists a run to history on the first event stop', async () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
// persistence happens off the event loop
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(listRuns()).toHaveLength(1);
|
||||
expect(listRuns()[0].rundownId).toBe('rundown-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeRun()', () => {
|
||||
it('closes the open run and starts a fresh one on the next event', async () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
|
||||
|
||||
closeRun();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(listRuns()).toHaveLength(1);
|
||||
expect(listRuns()[0].endedAt).toBe(10000);
|
||||
|
||||
// a new start after closing opens a second run rather than reusing the first
|
||||
const secondStart = makeRuntimeStateData({ eventNow: eventB, timer: { startedAt: 20000 }, clock: 20000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, secondStart);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...secondStart, clock: 30000 } as typeof secondStart);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(listRuns()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does nothing when no run is open', () => {
|
||||
expect(() => closeRun()).not.toThrow();
|
||||
expect(listRuns()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initReports()', () => {
|
||||
it('closes a dangling run left open by a crash or shutdown', async () => {
|
||||
runs = [
|
||||
{
|
||||
id: 'dangling',
|
||||
rundownId: 'rundown-1',
|
||||
rundownTitle: 'Test rundown',
|
||||
label: 'unfinished',
|
||||
startedAt: 0,
|
||||
endedAt: null,
|
||||
report: {
|
||||
[eventA.id]: {
|
||||
startedAt: 0,
|
||||
endedAt: 9000,
|
||||
scheduledStart: 0,
|
||||
scheduledDuration: 10000,
|
||||
playCount: 1,
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
eventsRun: 1,
|
||||
eventsPlanned: 2,
|
||||
scheduledDuration: 10000,
|
||||
actualDuration: 9000,
|
||||
drift: -1000,
|
||||
eventsOver: 0,
|
||||
eventsUnder: 1,
|
||||
eventsOnTime: 0,
|
||||
worstOverrun: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await initReports('project-a');
|
||||
|
||||
const recovered = getRun('dangling');
|
||||
expect(recovered?.endedAt).toBe(9000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run history queries and edits', () => {
|
||||
async function makeClosedRun(id: string, rundownId = 'rundown-1') {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
currentRundown = { ...currentRundown, id: rundownId };
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
|
||||
closeRun();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
// stamp a predictable id so tests can address the run directly
|
||||
const created = listRuns()[0];
|
||||
runs[0] = { ...runs[0], id };
|
||||
return created;
|
||||
}
|
||||
|
||||
it('filters listRuns by rundown', async () => {
|
||||
await makeClosedRun('run-a', 'rundown-1');
|
||||
await makeClosedRun('run-b', 'rundown-2');
|
||||
|
||||
expect(listRuns('rundown-1').map((run) => run.id)).toEqual(['run-a']);
|
||||
expect(listRuns('rundown-2').map((run) => run.id)).toEqual(['run-b']);
|
||||
expect(listRuns()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns the most recently started closed run', async () => {
|
||||
await makeClosedRun('older');
|
||||
await makeClosedRun('newer');
|
||||
runs.find((run) => run.id === 'older')!.startedAt = 0;
|
||||
runs.find((run) => run.id === 'newer')!.startedAt = 100000;
|
||||
|
||||
expect(getLatestRun()?.id).toBe('newer');
|
||||
});
|
||||
|
||||
it('renames a run', async () => {
|
||||
await makeClosedRun('run-a');
|
||||
const renamed = await renameRun('run-a', 'Dress rehearsal');
|
||||
expect(renamed?.label).toBe('Dress rehearsal');
|
||||
expect(getRun('run-a')?.label).toBe('Dress rehearsal');
|
||||
});
|
||||
|
||||
it('returns undefined when renaming a run that does not exist', async () => {
|
||||
expect(await renameRun('missing', 'x')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes a single run', async () => {
|
||||
await makeClosedRun('run-a');
|
||||
expect(await deleteRun('run-a')).toBe(true);
|
||||
expect(getRun('run-a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the in-progress report when the open run is deleted', async () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const openRunId = listRuns()[0].id;
|
||||
await deleteRun(openRunId);
|
||||
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
|
||||
it('deletes all run history', async () => {
|
||||
await makeClosedRun('run-a');
|
||||
await makeClosedRun('run-b');
|
||||
await deleteAllRuns();
|
||||
expect(listRuns()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('clears a single event from the in-progress report', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
clear(eventA.id);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
|
||||
it('clears the entire in-progress report', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
clear();
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,80 @@ import type { Request, Response, Router } from 'express';
|
||||
|
||||
import { paramsWithId } from '../validation-utils/validationFunction.js';
|
||||
import * as report from './report.service.js';
|
||||
import { validateRundownIdQuery, validateRunLabel } from './report.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
|
||||
/**
|
||||
* Current run's report, kept unchanged so existing HTTP automations and the
|
||||
* Companion module are unaffected.
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.status(200).json(report.generate());
|
||||
});
|
||||
|
||||
router.delete('/all', (_req: Request, res: Response) => {
|
||||
report.clear();
|
||||
/**
|
||||
* Run history, most recent first. `?rundownId=` scopes the list to one rundown.
|
||||
*/
|
||||
router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => {
|
||||
const { rundownId } = req.query as { rundownId?: string };
|
||||
res.status(200).json(report.listRuns(rundownId));
|
||||
});
|
||||
|
||||
/**
|
||||
* Most recently closed run, used to compare a rundown against its last outing.
|
||||
* Registered ahead of /runs/:id so "latest" is not read as an id.
|
||||
*/
|
||||
router.get('/runs/latest', validateRundownIdQuery, (req: Request, res: Response) => {
|
||||
const { rundownId } = req.query as { rundownId?: string };
|
||||
const run = report.getLatestRun(rundownId);
|
||||
if (!run) {
|
||||
res.status(404).send();
|
||||
return;
|
||||
}
|
||||
res.status(200).json(run);
|
||||
});
|
||||
|
||||
router.get('/runs/:id', paramsWithId, (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const run = report.getRun(id);
|
||||
if (!run) {
|
||||
res.status(404).send();
|
||||
return;
|
||||
}
|
||||
res.status(200).json(run);
|
||||
});
|
||||
|
||||
/**
|
||||
* Renames a run, the only field a user can edit after the fact.
|
||||
*/
|
||||
router.patch('/runs/:id', validateRunLabel, async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { label } = req.body as { label: string };
|
||||
const run = await report.renameRun(id, label);
|
||||
if (!run) {
|
||||
res.status(404).send();
|
||||
return;
|
||||
}
|
||||
res.status(200).json(run);
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes a single run, eg: a test run that should not pollute the history.
|
||||
*/
|
||||
router.delete('/runs/:id', paramsWithId, async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const didDelete = await report.deleteRun(id);
|
||||
if (!didDelete) {
|
||||
res.status(404).send();
|
||||
return;
|
||||
}
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
router.delete('/all', async (_req: Request, res: Response) => {
|
||||
// clears both the run history and the report of the run in progress
|
||||
await report.deleteAllRuns();
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
|
||||
import {
|
||||
EntryId,
|
||||
OntimeEventReport,
|
||||
OntimeReport,
|
||||
RefetchKey,
|
||||
ShowRun,
|
||||
ShowRunSummary,
|
||||
TimerLifeCycle,
|
||||
} from 'ontime-types';
|
||||
import { countPlannedEvents, generateId, getRunSummary } from 'ontime-utils';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import * as reportStore from '../../services/report-service/report.store.js';
|
||||
import { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import { getCurrentRundown } from '../rundown/rundown.dao.js';
|
||||
|
||||
const report = new Map<string, OntimeEventReport>();
|
||||
/** per event data for the run currently in progress */
|
||||
const report = new Map<EntryId, OntimeEventReport>();
|
||||
|
||||
let formattedReport: OntimeReport | null = null;
|
||||
|
||||
/** metadata for the run in progress, null when no run is open */
|
||||
let openRun: Omit<ShowRun, 'report' | 'summary'> | null = null;
|
||||
|
||||
/**
|
||||
* generates a full report
|
||||
* @returns full report
|
||||
@@ -30,6 +45,7 @@ export function clear(id?: string) {
|
||||
} else {
|
||||
report.clear();
|
||||
}
|
||||
void persistOpenRun();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,15 +65,197 @@ export function triggerReportEntry(
|
||||
const eventId = state.eventNow.id;
|
||||
|
||||
if (cycle === TimerLifeCycle.onStart) {
|
||||
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
|
||||
openRunIfNeeded(state);
|
||||
|
||||
// an event started twice in the same run is a re-run, not a new record
|
||||
const playCount = (report.get(eventId)?.playCount ?? 0) + 1;
|
||||
|
||||
report.set(eventId, {
|
||||
startedAt: state.timer.startedAt,
|
||||
endedAt: null,
|
||||
// snapshot the schedule so later rundown edits cannot rewrite this run
|
||||
scheduledStart: state.eventNow.timeStart,
|
||||
scheduledDuration: state.eventNow.duration,
|
||||
playCount,
|
||||
});
|
||||
formattedReport = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cycle === TimerLifeCycle.onStop) {
|
||||
const startedAt = report.get(eventId)?.startedAt ?? null;
|
||||
report.set(eventId, { startedAt, endedAt: state.clock });
|
||||
const previous = report.get(eventId);
|
||||
report.set(eventId, {
|
||||
startedAt: previous?.startedAt ?? null,
|
||||
endedAt: state.clock,
|
||||
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
|
||||
scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration,
|
||||
playCount: previous?.playCount ?? 1,
|
||||
});
|
||||
formattedReport = null;
|
||||
void persistOpenRun();
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the run in progress.
|
||||
* Called when playback stops and events are unloaded, which is the operator
|
||||
* saying the show is over. Pausing or loading another event does not end a run.
|
||||
*/
|
||||
export function closeRun() {
|
||||
if (openRun === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// detach the run before the async write so a start arriving in between
|
||||
// opens a new run instead of appending to the one we are closing
|
||||
const closing = { ...openRun, endedAt: lastEndedAt() };
|
||||
openRun = null;
|
||||
|
||||
void persistRun(closing, generate());
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a run on the first event start after the previous run closed.
|
||||
* The in progress report is reset here rather than on close, so the rundown
|
||||
* chips keep showing the run that just finished.
|
||||
* @private
|
||||
*/
|
||||
function openRunIfNeeded(state: DeepReadonly<RuntimeState>) {
|
||||
if (openRun !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
report.clear();
|
||||
formattedReport = null;
|
||||
|
||||
const rundown = getCurrentRundown();
|
||||
const startedAt = state.rundown.actualStart ?? state.clock;
|
||||
|
||||
openRun = {
|
||||
id: generateId(),
|
||||
rundownId: rundown.id,
|
||||
rundownTitle: rundown.title,
|
||||
label: new Date().toISOString(),
|
||||
startedAt,
|
||||
endedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the run in progress to the sidecar.
|
||||
* Persisting on every event stop means an interrupted show still leaves a record.
|
||||
* @private
|
||||
*/
|
||||
async function persistOpenRun(): Promise<void> {
|
||||
if (openRun === null) {
|
||||
return;
|
||||
}
|
||||
await persistRun(openRun, generate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a run and its derived summary to the sidecar
|
||||
* @private
|
||||
*/
|
||||
async function persistRun(run: Omit<ShowRun, 'report' | 'summary'>, currentReport: OntimeReport): Promise<void> {
|
||||
const rundown = getCurrentRundown();
|
||||
const eventsPlanned = countPlannedEvents(rundown.entries, rundown.flatOrder);
|
||||
|
||||
await reportStore.upsertRun({
|
||||
...run,
|
||||
report: structuredClone(currentReport),
|
||||
summary: getRunSummary(currentReport, eventsPlanned),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp of the last event to finish in this run
|
||||
* @private
|
||||
*/
|
||||
function lastEndedAt(): number | null {
|
||||
let latest: number | null = null;
|
||||
for (const entry of report.values()) {
|
||||
if (entry.endedAt !== null && (latest === null || entry.endedAt > latest)) {
|
||||
latest = entry.endedAt;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares reporting for a newly loaded project.
|
||||
* Any run left open by a crash or shutdown is closed against its own data
|
||||
* so it cannot absorb events from the next show.
|
||||
*/
|
||||
export async function initReports(projectFilename: string): Promise<void> {
|
||||
report.clear();
|
||||
formattedReport = null;
|
||||
openRun = null;
|
||||
|
||||
await reportStore.loadReports(projectFilename);
|
||||
|
||||
const dangling = reportStore.getRuns().find((run) => run.endedAt === null);
|
||||
if (dangling) {
|
||||
const endedAt = Object.values(dangling.report).reduce<number | null>((latest, entry) => {
|
||||
if (entry.endedAt === null) return latest;
|
||||
return latest === null || entry.endedAt > latest ? entry.endedAt : latest;
|
||||
}, null);
|
||||
await reportStore.upsertRun({ ...dangling, endedAt });
|
||||
}
|
||||
}
|
||||
|
||||
/** Run history for the current project, without per event data */
|
||||
export function listRuns(rundownId?: string): ShowRunSummary[] {
|
||||
return reportStore
|
||||
.getRuns()
|
||||
.filter((run) => rundownId === undefined || run.rundownId === rundownId)
|
||||
.map(({ report: _report, ...rest }) => rest);
|
||||
}
|
||||
|
||||
export function getRun(id: string): ShowRun | undefined {
|
||||
return reportStore.getRun(id);
|
||||
}
|
||||
|
||||
/** Most recent closed run, used to compare a rundown against its last outing */
|
||||
export function getLatestRun(rundownId?: string): ShowRun | undefined {
|
||||
return reportStore
|
||||
.getRuns()
|
||||
.filter((run) => run.endedAt !== null && (rundownId === undefined || run.rundownId === rundownId))
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.at(0);
|
||||
}
|
||||
|
||||
export async function renameRun(id: string, label: string): Promise<ShowRun | undefined> {
|
||||
const run = reportStore.getRun(id);
|
||||
if (!run) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const renamed = { ...run, label };
|
||||
await reportStore.upsertRun(renamed);
|
||||
sendRefetch(RefetchKey.Report);
|
||||
return renamed;
|
||||
}
|
||||
|
||||
export async function deleteRun(id: string): Promise<boolean> {
|
||||
const didDelete = await reportStore.deleteRun(id);
|
||||
if (didDelete) {
|
||||
if (openRun?.id === id) {
|
||||
openRun = null;
|
||||
report.clear();
|
||||
formattedReport = null;
|
||||
}
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
return didDelete;
|
||||
}
|
||||
|
||||
export async function deleteAllRuns(): Promise<void> {
|
||||
await reportStore.deleteAllRuns();
|
||||
openRun = null;
|
||||
report.clear();
|
||||
formattedReport = null;
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { body, param, query } from 'express-validator';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
|
||||
export const validateRundownIdQuery = [
|
||||
query('rundownId').optional().isString().trim().notEmpty(),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateRunLabel = [
|
||||
param('id').isString().trim().notEmpty(),
|
||||
body('label').isString().trim().notEmpty(),
|
||||
requestValidationFunction,
|
||||
];
|
||||
@@ -25,6 +25,7 @@ import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { makeNewRundown } from '../../models/dataModel.js';
|
||||
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
||||
import { deleteRunsForRundown } from '../../services/report-service/report.store.js';
|
||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||
@@ -892,6 +893,9 @@ export async function deleteRundown(id: string) {
|
||||
|
||||
const projectRundowns = await dataProvider.deleteRundown(id);
|
||||
|
||||
// a rundown's run history has no meaning once the rundown is gone
|
||||
await deleteRunsForRundown(id);
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
||||
|
||||
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
|
||||
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
|
||||
import { initReports } from '../../api-data/report/report.service.js';
|
||||
import { deleteReportsForProject, renameReportsForProject } from '../report-service/report.store.js';
|
||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
||||
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||
@@ -63,6 +65,7 @@ function init() {
|
||||
ensureDirectory(publicDir.corruptDir);
|
||||
ensureDirectory(publicDir.logoDir);
|
||||
ensureDirectory(publicDir.migrateDir);
|
||||
ensureDirectory(publicDir.reportsDir);
|
||||
}
|
||||
|
||||
export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> {
|
||||
@@ -88,6 +91,9 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
|
||||
// stop the runtime service
|
||||
runtimeService.stop();
|
||||
|
||||
// point reporting at this project's sidecar, reports do not cross projects
|
||||
await initReports(fileName);
|
||||
|
||||
// load the rundown given by key otherwise load the first in the project
|
||||
const rundown =
|
||||
rundownId && rundownId in projectData.rundowns
|
||||
@@ -263,6 +269,8 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
|
||||
|
||||
const pathToDuplicate = getPathToProject(newFilename);
|
||||
await copyFile(projectFilePath, pathToDuplicate);
|
||||
// deliberately not copying report history: a duplicate is a new show and
|
||||
// inheriting another project's run history would be misleading
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,6 +292,9 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
|
||||
const pathToRenamed = getPathToProject(newFilename);
|
||||
await dockerSafeRename(projectFilePath, pathToRenamed);
|
||||
|
||||
// run history follows the project it belongs to
|
||||
await renameReportsForProject(originalFile, newFilename);
|
||||
|
||||
// Update the last loaded project config if current loaded project is the one being renamed
|
||||
const isLoaded = await isLastLoadedProject(originalFile);
|
||||
if (isLoaded) {
|
||||
@@ -332,6 +343,8 @@ export async function deleteProjectFile(filename: string) {
|
||||
}
|
||||
|
||||
await deleteFile(projectFilePath);
|
||||
// reports are owned by their project and do not outlive it
|
||||
await deleteReportsForProject(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ShowRun } from 'ontime-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// in-memory stand-in for the JSON file on disk, keyed by path
|
||||
const files = new Map<string, unknown>();
|
||||
|
||||
vi.mock('lowdb/node', () => {
|
||||
class JSONFile {
|
||||
private path: string;
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
}
|
||||
async read() {
|
||||
return files.has(this.path) ? files.get(this.path) : null;
|
||||
}
|
||||
async write(data: unknown) {
|
||||
files.set(this.path, data);
|
||||
}
|
||||
}
|
||||
return { JSONFile };
|
||||
});
|
||||
|
||||
vi.mock('../../../utils/fileManagement.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils/fileManagement.js')>(
|
||||
'../../../utils/fileManagement.js',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
deleteFile: vi.fn(async (path: string) => {
|
||||
files.delete(path);
|
||||
}),
|
||||
dockerSafeRename: vi.fn(async (oldPath: string, newPath: string) => {
|
||||
if (files.has(oldPath)) {
|
||||
files.set(newPath, files.get(oldPath));
|
||||
files.delete(oldPath);
|
||||
}
|
||||
}),
|
||||
statIfExists: vi.fn(async (path: string) => (files.has(path) ? {} : null)),
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
loadReports,
|
||||
getRuns,
|
||||
getRun,
|
||||
upsertRun,
|
||||
deleteRun,
|
||||
deleteRunsForRundown,
|
||||
deleteAllRuns,
|
||||
deleteReportsForProject,
|
||||
renameReportsForProject,
|
||||
resetStore,
|
||||
getPathToReports,
|
||||
} = await import('../report.store.js');
|
||||
|
||||
function makeRun(patch: Partial<ShowRun> = {}): ShowRun {
|
||||
return {
|
||||
id: 'run-1',
|
||||
rundownId: 'rundown-1',
|
||||
rundownTitle: 'My rundown',
|
||||
label: '2026-08-08',
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
report: {},
|
||||
summary: {
|
||||
eventsRun: 0,
|
||||
eventsPlanned: 0,
|
||||
scheduledDuration: 0,
|
||||
actualDuration: 0,
|
||||
drift: 0,
|
||||
eventsOver: 0,
|
||||
eventsUnder: 0,
|
||||
eventsOnTime: 0,
|
||||
worstOverrun: null,
|
||||
},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
files.clear();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
describe('loadReports()', () => {
|
||||
it('starts empty for a project with no sidecar', async () => {
|
||||
const result = await loadReports('project-a');
|
||||
expect(result.runs).toEqual([]);
|
||||
expect(getRuns()).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads runs already on disk', async () => {
|
||||
files.set(getPathToReports('project-a'), { runs: [makeRun()] });
|
||||
const result = await loadReports('project-a');
|
||||
expect(result.runs).toHaveLength(1);
|
||||
expect(getRuns()[0].id).toBe('run-1');
|
||||
});
|
||||
|
||||
it('discards a corrupt sidecar rather than throwing', async () => {
|
||||
files.set(getPathToReports('project-a'), { runs: 'not-an-array' });
|
||||
const result = await loadReports('project-a');
|
||||
expect(result.runs).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes runs to the loaded project', async () => {
|
||||
files.set(getPathToReports('project-a'), { runs: [makeRun({ id: 'a' })] });
|
||||
files.set(getPathToReports('project-b'), { runs: [makeRun({ id: 'b' })] });
|
||||
|
||||
await loadReports('project-a');
|
||||
expect(getRuns().map((run) => run.id)).toEqual(['a']);
|
||||
|
||||
await loadReports('project-b');
|
||||
expect(getRuns().map((run) => run.id)).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertRun() / getRun() / getRuns()', () => {
|
||||
beforeEach(async () => {
|
||||
await loadReports('project-a');
|
||||
});
|
||||
|
||||
it('inserts a new run at the front of the list', async () => {
|
||||
await upsertRun(makeRun({ id: 'first' }));
|
||||
await upsertRun(makeRun({ id: 'second' }));
|
||||
expect(getRuns().map((run) => run.id)).toEqual(['second', 'first']);
|
||||
});
|
||||
|
||||
it('replaces an existing run in place rather than duplicating it', async () => {
|
||||
await upsertRun(makeRun({ id: 'run-1', label: 'first pass' }));
|
||||
await upsertRun(makeRun({ id: 'run-1', label: 'renamed' }));
|
||||
|
||||
expect(getRuns()).toHaveLength(1);
|
||||
expect(getRun('run-1')?.label).toBe('renamed');
|
||||
});
|
||||
|
||||
it('persists to disk', async () => {
|
||||
await upsertRun(makeRun());
|
||||
const reloaded = await loadReports('project-a');
|
||||
expect(reloaded.runs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRun()', () => {
|
||||
beforeEach(async () => {
|
||||
await loadReports('project-a');
|
||||
await upsertRun(makeRun({ id: 'keep' }));
|
||||
await upsertRun(makeRun({ id: 'discard' }));
|
||||
});
|
||||
|
||||
it('removes only the targeted run', async () => {
|
||||
const didDelete = await deleteRun('discard');
|
||||
expect(didDelete).toBe(true);
|
||||
expect(getRuns().map((run) => run.id)).toEqual(['keep']);
|
||||
});
|
||||
|
||||
it('reports false for a run that does not exist', async () => {
|
||||
expect(await deleteRun('missing')).toBe(false);
|
||||
expect(getRuns()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRunsForRundown()', () => {
|
||||
it('removes only runs belonging to the given rundown', async () => {
|
||||
await loadReports('project-a');
|
||||
await upsertRun(makeRun({ id: 'a', rundownId: 'rundown-x' }));
|
||||
await upsertRun(makeRun({ id: 'b', rundownId: 'rundown-y' }));
|
||||
await upsertRun(makeRun({ id: 'c', rundownId: 'rundown-x' }));
|
||||
|
||||
const removed = await deleteRunsForRundown('rundown-x');
|
||||
|
||||
expect(removed).toBe(2);
|
||||
expect(getRuns().map((run) => run.id)).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllRuns()', () => {
|
||||
it('empties the run history', async () => {
|
||||
await loadReports('project-a');
|
||||
await upsertRun(makeRun());
|
||||
await deleteAllRuns();
|
||||
expect(getRuns()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project lifecycle', () => {
|
||||
it('deletes the sidecar for a project', async () => {
|
||||
await loadReports('project-a');
|
||||
await upsertRun(makeRun());
|
||||
expect(files.has(getPathToReports('project-a'))).toBe(true);
|
||||
|
||||
await deleteReportsForProject('project-a');
|
||||
expect(files.has(getPathToReports('project-a'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing when the project never had a sidecar', async () => {
|
||||
await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('moves the sidecar to follow a project rename', async () => {
|
||||
await loadReports('project-a');
|
||||
await upsertRun(makeRun());
|
||||
|
||||
await renameReportsForProject('project-a', 'project-b');
|
||||
|
||||
expect(files.has(getPathToReports('project-a'))).toBe(false);
|
||||
const moved = files.get(getPathToReports('project-b')) as { runs: ShowRun[] };
|
||||
expect(moved.runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing when renaming a project that never had a sidecar', async () => {
|
||||
await expect(renameReportsForProject('never-loaded', 'still-never-loaded')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { OntimeEventReport, ProjectReports, RunSummary, ShowRun } from 'ontime-types';
|
||||
|
||||
import { is } from '../../utils/is.js';
|
||||
|
||||
/**
|
||||
* Validates the contents of a report sidecar file.
|
||||
* A file which fails validation is discarded rather than repaired: reports are
|
||||
* a record, and a partially understood record is worse than an empty one.
|
||||
*/
|
||||
export function isProjectReports(value: unknown): value is ProjectReports {
|
||||
if (!is.object(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.objectWithKeys(value, ['runs'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.array(value.runs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.runs.every(isShowRun);
|
||||
}
|
||||
|
||||
function isShowRun(value: unknown): value is ShowRun {
|
||||
if (!is.object(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!is.objectWithKeys(value, [
|
||||
'id',
|
||||
'rundownId',
|
||||
'rundownTitle',
|
||||
'label',
|
||||
'startedAt',
|
||||
'endedAt',
|
||||
'report',
|
||||
'summary',
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.string(value.id) || !is.string(value.rundownId) || !is.string(value.rundownTitle)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.string(value.label)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(value.startedAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(value.endedAt) && value.endedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isOntimeReport(value.report)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isRunSummary(value.summary);
|
||||
}
|
||||
|
||||
function isOntimeReport(value: unknown): value is Record<string, OntimeEventReport> {
|
||||
if (!is.object(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(value).every(isEventReport);
|
||||
}
|
||||
|
||||
function isEventReport(value: unknown): value is OntimeEventReport {
|
||||
if (!is.object(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.objectWithKeys(value, ['startedAt', 'endedAt', 'scheduledStart', 'scheduledDuration', 'playCount'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(value.startedAt) && value.startedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(value.endedAt) && value.endedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is.number(value.scheduledStart) && is.number(value.scheduledDuration) && is.number(value.playCount);
|
||||
}
|
||||
|
||||
function isRunSummary(value: unknown): value is RunSummary {
|
||||
if (!is.object(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const numericKeys = [
|
||||
'eventsRun',
|
||||
'eventsPlanned',
|
||||
'scheduledDuration',
|
||||
'actualDuration',
|
||||
'drift',
|
||||
'eventsOver',
|
||||
'eventsUnder',
|
||||
'eventsOnTime',
|
||||
] as const;
|
||||
|
||||
if (!is.objectWithKeys(value, [...numericKeys, 'worstOverrun'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!numericKeys.every((key) => is.number(value[key]))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.worstOverrun === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!is.object(value.worstOverrun) || !is.objectWithKeys(value.worstOverrun, ['id', 'delta'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is.string(value.worstOverrun.id) && is.number(value.worstOverrun.delta);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import type { ProjectReports, ShowRun } from 'ontime-types';
|
||||
|
||||
import { publicDir } from '../../setup/index.js';
|
||||
import { deleteFile, dockerSafeRename, ensureJsonExtension, statIfExists } from '../../utils/fileManagement.js';
|
||||
import { isProjectReports } from './report.parser.js';
|
||||
|
||||
/**
|
||||
* Reports are kept in a sidecar file per project rather than in the project
|
||||
* itself. This keeps the project file free of mid-show writes and lets the
|
||||
* run history grow without bloating what the user exports.
|
||||
*
|
||||
* Persistence is best effort by design: a failing disk degrades reporting
|
||||
* but must never interrupt a running show.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns a fresh empty store.
|
||||
* Must not be a shared constant: `cache.runs` is mutated in place elsewhere
|
||||
* in this module, and a shared array would leak state between projects.
|
||||
*/
|
||||
function emptyStore(): ProjectReports {
|
||||
return { runs: [] };
|
||||
}
|
||||
|
||||
let fileRef: JSONFile<ProjectReports> | null = null;
|
||||
let cache: ProjectReports = emptyStore();
|
||||
let failedWriteAttempts = 0;
|
||||
|
||||
/**
|
||||
* Resolves the sidecar path for a given project file name
|
||||
*/
|
||||
export function getPathToReports(projectFilename: string): string {
|
||||
return join(publicDir.reportsDir, ensureJsonExtension(projectFilename));
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the store at a project's sidecar and loads whatever is on disk.
|
||||
* Called on every project load, which is the single choke point for
|
||||
* project changes.
|
||||
*/
|
||||
export async function loadReports(projectFilename: string): Promise<ProjectReports> {
|
||||
fileRef = new JSONFile<ProjectReports>(getPathToReports(projectFilename));
|
||||
failedWriteAttempts = 0;
|
||||
|
||||
try {
|
||||
const maybeReports = await fileRef.read();
|
||||
cache = isProjectReports(maybeReports) ? maybeReports : emptyStore();
|
||||
} catch (_error) {
|
||||
// a missing or corrupt sidecar is not worth interrupting a project load over
|
||||
cache = emptyStore();
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the runs held for the current project, newest first
|
||||
*/
|
||||
export function getRuns(): ShowRun[] {
|
||||
return cache.runs;
|
||||
}
|
||||
|
||||
export function getRun(id: string): ShowRun | undefined {
|
||||
return cache.runs.find((run) => run.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or replaces a run, keeping the list ordered newest first
|
||||
*/
|
||||
export async function upsertRun(run: ShowRun): Promise<void> {
|
||||
const index = cache.runs.findIndex((candidate) => candidate.id === run.id);
|
||||
if (index === -1) {
|
||||
cache.runs.unshift(run);
|
||||
} else {
|
||||
cache.runs[index] = run;
|
||||
}
|
||||
await persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a single run, used to discard a test run from the history
|
||||
* @returns whether a run was found and removed
|
||||
*/
|
||||
export async function deleteRun(id: string): Promise<boolean> {
|
||||
const index = cache.runs.findIndex((run) => run.id === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cache.runs.splice(index, 1);
|
||||
await persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every run belonging to a rundown, cascaded from rundown deletion
|
||||
* @returns how many runs were removed
|
||||
*/
|
||||
export async function deleteRunsForRundown(rundownId: string): Promise<number> {
|
||||
const before = cache.runs.length;
|
||||
cache.runs = cache.runs.filter((run) => run.rundownId !== rundownId);
|
||||
|
||||
const removed = before - cache.runs.length;
|
||||
if (removed > 0) {
|
||||
await persist();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the run history of the current project
|
||||
*/
|
||||
export async function deleteAllRuns(): Promise<void> {
|
||||
cache.runs = [];
|
||||
await persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a project's sidecar from disk.
|
||||
* Reports are owned by their project and do not outlive it.
|
||||
*/
|
||||
export async function deleteReportsForProject(projectFilename: string): Promise<void> {
|
||||
const path = getPathToReports(projectFilename);
|
||||
try {
|
||||
if ((await statIfExists(path)) !== null) {
|
||||
await deleteFile(path);
|
||||
}
|
||||
} catch (_error) {
|
||||
// a leftover sidecar is harmless, deleting the project must still succeed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a project's sidecar so run history follows a project rename
|
||||
*/
|
||||
export async function renameReportsForProject(originalFilename: string, newFilename: string): Promise<void> {
|
||||
const originalPath = getPathToReports(originalFilename);
|
||||
const newPath = getPathToReports(newFilename);
|
||||
|
||||
try {
|
||||
if ((await statIfExists(originalPath)) === null) {
|
||||
return;
|
||||
}
|
||||
await dockerSafeRename(originalPath, newPath);
|
||||
// keep the reference pointing at the file we just moved
|
||||
if (fileRef) {
|
||||
fileRef = new JSONFile<ProjectReports>(newPath);
|
||||
}
|
||||
} catch (_error) {
|
||||
// losing history on rename is bad but not fatal, the project rename stands
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the cache to disk.
|
||||
* Gives up after repeated failures so a broken disk cannot stall the runtime.
|
||||
* @private
|
||||
*/
|
||||
async function persist(): Promise<void> {
|
||||
if (fileRef === null || failedWriteAttempts > 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fileRef.write(cache);
|
||||
failedWriteAttempts = 0;
|
||||
} catch (_error) {
|
||||
failedWriteAttempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets in-memory state, used when no project is loaded and in tests
|
||||
*/
|
||||
export function resetStore(): void {
|
||||
fileRef = null;
|
||||
cache = emptyStore();
|
||||
failedWriteAttempts = 0;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { millisToString, validatePlayback } from 'ontime-utils';
|
||||
|
||||
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
|
||||
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
||||
import { closeRun, triggerReportEntry } from '../../api-data/report/report.service.js';
|
||||
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
||||
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
@@ -523,6 +523,8 @@ class RuntimeService {
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||
// a full stop unloads the events, which is the operator ending the show
|
||||
closeRun();
|
||||
triggerAutomations(TimerLifeCycle.onStop);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const config = {
|
||||
external: 'external',
|
||||
demo: 'demo',
|
||||
projects: 'projects',
|
||||
reports: 'reports',
|
||||
sheets: {
|
||||
directory: 'sheets',
|
||||
},
|
||||
|
||||
@@ -124,6 +124,8 @@ export const publicDir = {
|
||||
crashDir: join(resolvePublicDirectory, config.crash),
|
||||
/** path to projects folder */
|
||||
projectsDir: join(resolvePublicDirectory, config.projects),
|
||||
/** path to show reports folder, one sidecar file per project */
|
||||
reportsDir: join(resolvePublicDirectory, config.reports),
|
||||
/** path to corrupt folder */
|
||||
corruptDir: join(resolvePublicDirectory, config.corrupt),
|
||||
/** path to migrated folder */
|
||||
|
||||
Reference in New Issue
Block a user