refactor(report): finish via playback, one file per run

Uses the Finish the app already has, and stops rewriting the whole
history every time a show ends.

- The Go button already reads "Finish" on the last event and calls stop
  (playbackControl.utils). Closing the run hangs off that instead of a
  second Finish button of its own, so POST /report/runs/finish and the
  button in the overview are both gone. The run indicator stays as the
  way the feature is found, now a passive status that opens the reports
  panel.
- Reports move from one file per project to a directory per project with
  one file per run. Finishing a show wrote the entire history back to
  disk, which grew with every show ever recorded; it now writes only the
  run that just ended. Deleting a run unlinks one file and deleting a
  project removes one directory.
- A report that cannot be read is skipped instead of taking the rest of
  the history with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
Claude
2026-08-09 14:59:57 +00:00
parent b21385ec4a
commit a851414d13
11 changed files with 262 additions and 223 deletions
-8
View File
@@ -60,14 +60,6 @@ export async function fetchOpenRun(options?: RequestOptions): Promise<OpenRun |
} }
} }
/**
* HTTP request to finish the run in progress, which is what writes it to history
*/
export async function finishRun(): Promise<void> {
await axios.post(`${reportUrl}/runs/finish`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function renameRun(id: string, label: string): Promise<ShowRun> { export async function renameRun(id: string, label: string): Promise<ShowRun> {
const res = await axios.patch(`${reportUrl}/runs/${id}`, { label }); const res = await axios.patch(`${reportUrl}/runs/${id}`, { label });
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
@@ -1,16 +1,20 @@
.indicator { .indicator {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
white-space: nowrap;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
color: $label-gray; color: $label-gray;
&:hover {
color: $ui-white;
}
} }
.dot { .dot {
@@ -1,58 +1,39 @@
import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { finishRun } from '../../../common/api/report';
import Button from '../../../common/components/buttons/Button';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useOpenRun } from '../../../common/hooks-query/useRuns'; import { useOpenRun } from '../../../common/hooks-query/useRuns';
import style from './RunIndicator.module.scss'; import style from './RunIndicator.module.scss';
/** /**
* Shows that a show report is being recorded, and lets the operator close it. * Shows that a report is being recorded for the show in progress.
* *
* This is the feature's home in the editor: it appears only while a run is * This is how the feature is discovered: it appears with the first event and
* open, so it stays out of the way until it is relevant, and finishing here * points at the reports panel. Ending the run is the existing Finish action
* is what writes the run to history. * on the last event, so there is no separate control here.
*/ */
export default function RunIndicator() { export default function RunIndicator() {
const openRun = useOpenRun(); const openRun = useOpenRun();
const [isFinishing, setIsFinishing] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
if (!openRun) { if (!openRun) {
return null; return null;
} }
const handleFinish = async () => { // startedAt is a wall clock instant, not a time of day, so formatTime does not apply
setIsFinishing(true); const since = new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' });
try {
await finishRun();
// take the user to the report they just made, which is also how most
// people will discover that the history exists
void navigate('/editor?settings=sharing__report');
} catch (_error) {
/** the run stays open, the user can try again */
} finally {
setIsFinishing(false);
}
};
return ( return (
<div className={style.indicator}> <Tooltip text='A report is being recorded. It is saved when you finish the show.' render={<span />}>
<Tooltip text='A report is being recorded for this show' render={<span />}> <button
<span className={style.label}> type='button'
<span className={style.dot} /> className={style.indicator}
{/* startedAt is a wall clock instant, not a time of day, so it is not formatTime's job */} onClick={() => navigate('/editor?settings=sharing__report')}
Recording since{' '} aria-label='Show reports'
<span className={style.since}> >
{new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' })} <span className={style.dot} />
</span> Recording since <span className={style.since}>{since}</span>
</span> </button>
</Tooltip> </Tooltip>
<Button size='small' variant='subtle' onClick={handleFinish} disabled={isFinishing}>
Finish run
</Button>
</div>
); );
} }
@@ -13,7 +13,7 @@ let writes = 0;
vi.mock('../../../services/report-service/report.store.js', () => ({ vi.mock('../../../services/report-service/report.store.js', () => ({
// isolation between tests comes from the top-level beforeEach resetting `runs`, // isolation between tests comes from the top-level beforeEach resetting `runs`,
// this mirrors the real store returning whatever is already on "disk" // this mirrors the real store returning whatever is already on "disk"
loadReports: vi.fn(async () => ({ runs })), loadReports: vi.fn(async () => runs),
getRuns: vi.fn(() => runs), getRuns: vi.fn(() => runs),
getRun: vi.fn((id: string) => runs.find((run) => run.id === id)), getRun: vi.fn((id: string) => runs.find((run) => run.id === id)),
upsertRun: vi.fn(async (run: ShowRun) => { upsertRun: vi.fn(async (run: ShowRun) => {
@@ -37,18 +37,6 @@ router.get('/runs/open', (_req: Request, res: Response) => {
res.status(200).json(run); res.status(200).json(run);
}); });
/**
* Closes the run in progress and writes it to history immediately.
* Registered ahead of /runs/:id so "finish" is not read as an id.
*/
router.post('/runs/finish', async (_req: Request, res: Response) => {
const run = await report.closeRun();
if (!run) {
res.status(404).send();
return;
}
res.status(200).json(run);
});
router.get('/runs/:id', paramsWithId, (req: Request, res: Response) => { router.get('/runs/:id', paramsWithId, (req: Request, res: Response) => {
const { id } = req.params; const { id } = req.params;
@@ -1,7 +1,9 @@
import { join } from 'path';
import type { ShowRun } from 'ontime-types'; import type { ShowRun } from 'ontime-types';
import { vi } from 'vitest'; import { vi } from 'vitest';
// in-memory stand-in for the JSON file on disk, keyed by path // in-memory stand-in for the filesystem, keyed by absolute path
const files = new Map<string, unknown>(); const files = new Map<string, unknown>();
vi.mock('lowdb/node', () => { vi.mock('lowdb/node', () => {
@@ -24,18 +26,35 @@ vi.mock('../../../utils/fileManagement.js', async () => {
const actual = await vi.importActual<typeof import('../../../utils/fileManagement.js')>( const actual = await vi.importActual<typeof import('../../../utils/fileManagement.js')>(
'../../../utils/fileManagement.js', '../../../utils/fileManagement.js',
); );
const childrenOf = (dir: string) => [...files.keys()].filter((path) => path.startsWith(`${dir}/`));
return { return {
...actual, ...actual,
ensureDirectory: vi.fn(),
readDirectoryEntries: vi.fn(async (dir: string) => {
const children = childrenOf(dir);
if (children.length === 0) throw new Error('ENOENT');
return children.map((path) => ({
name: path.slice(dir.length + 1),
isFile: () => true,
}));
}),
deleteFile: vi.fn(async (path: string) => { deleteFile: vi.fn(async (path: string) => {
files.delete(path); files.delete(path);
}), }),
dockerSafeRename: vi.fn(async (oldPath: string, newPath: string) => { deleteDirectory: vi.fn(async (dir: string) => {
if (files.has(oldPath)) { for (const path of childrenOf(dir)) files.delete(path);
files.set(newPath, files.get(oldPath)); }),
files.delete(oldPath); dockerSafeRename: vi.fn(async (oldDir: string, newDir: string) => {
for (const path of childrenOf(oldDir)) {
files.set(join(newDir, path.slice(oldDir.length + 1)), files.get(path));
files.delete(path);
} }
}), }),
statIfExists: vi.fn(async (path: string) => (files.has(path) ? {} : null)), statIfExists: vi.fn(async (path: string) =>
files.has(path) || childrenOf(path).length > 0 ? {} : null,
),
}; };
}); });
@@ -58,7 +77,7 @@ function makeRun(patch: Partial<ShowRun> = {}): ShowRun {
id: 'run-1', id: 'run-1',
rundownId: 'rundown-1', rundownId: 'rundown-1',
rundownTitle: 'My rundown', rundownTitle: 'My rundown',
label: '2026-08-08', label: '8 Aug 2026, 09:30',
startedAt: 1000, startedAt: 1000,
endedAt: 2000, endedAt: 2000,
report: {}, report: {},
@@ -77,34 +96,55 @@ function makeRun(patch: Partial<ShowRun> = {}): ShowRun {
}; };
} }
/** where a run's file lands for a given project */
const runPath = (project: string, id: string) => join(getPathToReports(project), `${id}.json`);
beforeEach(() => { beforeEach(() => {
files.clear(); files.clear();
resetStore(); resetStore();
}); });
describe('loadReports()', () => { describe('loadReports()', () => {
it('starts empty for a project with no sidecar', async () => { it('starts empty for a project with no reports', async () => {
const result = await loadReports('project-a'); expect(await loadReports('project-a')).toEqual([]);
expect(result.runs).toEqual([]);
expect(getRuns()).toEqual([]); expect(getRuns()).toEqual([]);
}); });
it('loads runs already on disk', async () => { it('resolves a directory per project, ignoring the file extension', async () => {
files.set(getPathToReports('project-a'), { runs: [makeRun()] }); expect(getPathToReports('my show.json')).toBe(getPathToReports('my show'));
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 () => { it('loads every run in the project directory', async () => {
files.set(getPathToReports('project-a'), { runs: 'not-an-array' }); files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
const result = await loadReports('project-a'); files.set(runPath('project-a', 'b'), makeRun({ id: 'b' }));
expect(result.runs).toEqual([]);
const runs = await loadReports('project-a');
expect(runs).toHaveLength(2);
});
it('presents runs newest first regardless of read order', async () => {
files.set(runPath('project-a', 'old'), makeRun({ id: 'old', startedAt: 1000 }));
files.set(runPath('project-a', 'new'), makeRun({ id: 'new', startedAt: 9000 }));
files.set(runPath('project-a', 'mid'), makeRun({ id: 'mid', startedAt: 5000 }));
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['new', 'mid', 'old']);
});
it('skips an unreadable report without losing the rest', async () => {
files.set(runPath('project-a', 'good'), makeRun({ id: 'good' }));
files.set(runPath('project-a', 'broken'), { nonsense: true });
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['good']);
}); });
it('scopes runs to the loaded project', async () => { it('scopes runs to the loaded project', async () => {
files.set(getPathToReports('project-a'), { runs: [makeRun({ id: 'a' })] }); files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
files.set(getPathToReports('project-b'), { runs: [makeRun({ id: 'b' })] }); files.set(runPath('project-b', 'b'), makeRun({ id: 'b' }));
await loadReports('project-a'); await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['a']); expect(getRuns().map((run) => run.id)).toEqual(['a']);
@@ -114,14 +154,18 @@ describe('loadReports()', () => {
}); });
}); });
describe('upsertRun() / getRun() / getRuns()', () => { describe('upsertRun()', () => {
beforeEach(async () => { beforeEach(async () => {
await loadReports('project-a'); await loadReports('project-a');
}); });
it('inserts a new run at the front of the list', async () => { it('writes only the run given, not the whole history', async () => {
await upsertRun(makeRun({ id: 'first' })); await upsertRun(makeRun({ id: 'first' }));
await upsertRun(makeRun({ id: 'second' })); await upsertRun(makeRun({ id: 'second' }));
// one file each, so the cost of finishing a show does not grow with history
expect(files.has(runPath('project-a', 'first'))).toBe(true);
expect(files.has(runPath('project-a', 'second'))).toBe(true);
expect(getRuns().map((run) => run.id)).toEqual(['second', 'first']); expect(getRuns().map((run) => run.id)).toEqual(['second', 'first']);
}); });
@@ -133,15 +177,12 @@ describe('upsertRun() / getRun() / getRuns()', () => {
expect(getRun('run-1')?.label).toBe('renamed'); expect(getRun('run-1')?.label).toBe('renamed');
}); });
it('persists to disk', async () => { it('survives a reload', async () => {
await upsertRun(makeRun()); await upsertRun(makeRun());
const reloaded = await loadReports('project-a'); expect(await loadReports('project-a')).toHaveLength(1);
expect(reloaded.runs).toHaveLength(1);
}); });
}); });
describe('deleteRun()', () => { describe('deleteRun()', () => {
beforeEach(async () => { beforeEach(async () => {
await loadReports('project-a'); await loadReports('project-a');
@@ -149,10 +190,12 @@ describe('deleteRun()', () => {
await upsertRun(makeRun({ id: 'discard' })); await upsertRun(makeRun({ id: 'discard' }));
}); });
it('removes only the targeted run', async () => { it('removes only the targeted run and its file', async () => {
const didDelete = await deleteRun('discard'); expect(await deleteRun('discard')).toBe(true);
expect(didDelete).toBe(true);
expect(getRuns().map((run) => run.id)).toEqual(['keep']); expect(getRuns().map((run) => run.id)).toEqual(['keep']);
expect(files.has(runPath('project-a', 'discard'))).toBe(false);
expect(files.has(runPath('project-a', 'keep'))).toBe(true);
}); });
it('reports false for a run that does not exist', async () => { it('reports false for a run that does not exist', async () => {
@@ -168,48 +211,65 @@ describe('deleteRunsForRundown()', () => {
await upsertRun(makeRun({ id: 'b', rundownId: 'rundown-y' })); await upsertRun(makeRun({ id: 'b', rundownId: 'rundown-y' }));
await upsertRun(makeRun({ id: 'c', rundownId: 'rundown-x' })); await upsertRun(makeRun({ id: 'c', rundownId: 'rundown-x' }));
const removed = await deleteRunsForRundown('rundown-x'); expect(await deleteRunsForRundown('rundown-x')).toBe(2);
expect(removed).toBe(2);
expect(getRuns().map((run) => run.id)).toEqual(['b']); expect(getRuns().map((run) => run.id)).toEqual(['b']);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-a', 'c'))).toBe(false);
}); });
}); });
describe('deleteAllRuns()', () => { describe('deleteAllRuns()', () => {
it('empties the run history', async () => { it('empties the project directory', async () => {
await loadReports('project-a'); await loadReports('project-a');
await upsertRun(makeRun()); await upsertRun(makeRun({ id: 'a' }));
await upsertRun(makeRun({ id: 'b' }));
await deleteAllRuns(); await deleteAllRuns();
expect(getRuns()).toEqual([]); expect(getRuns()).toEqual([]);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
}); });
}); });
describe('project lifecycle', () => { describe('project lifecycle', () => {
it('deletes the sidecar for a project', async () => { it('removes the whole directory with the project', async () => {
await loadReports('project-a'); await loadReports('project-a');
await upsertRun(makeRun()); await upsertRun(makeRun({ id: 'a' }));
expect(files.has(getPathToReports('project-a'))).toBe(true); await upsertRun(makeRun({ id: 'b' }));
await deleteReportsForProject('project-a'); await deleteReportsForProject('project-a');
expect(files.has(getPathToReports('project-a'))).toBe(false);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-a', 'b'))).toBe(false);
}); });
it('does nothing when the project never had a sidecar', async () => { it('does nothing when the project never had reports', async () => {
await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined(); await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined();
}); });
it('moves the sidecar to follow a project rename', async () => { it('moves the directory to follow a project rename', async () => {
await loadReports('project-a'); await loadReports('project-a');
await upsertRun(makeRun()); await upsertRun(makeRun({ id: 'a' }));
await renameReportsForProject('project-a', 'project-b'); await renameReportsForProject('project-a', 'project-b');
expect(files.has(getPathToReports('project-a'))).toBe(false); expect(files.has(runPath('project-a', 'a'))).toBe(false);
const moved = files.get(getPathToReports('project-b')) as { runs: ShowRun[] }; expect(files.has(runPath('project-b', 'a'))).toBe(true);
expect(moved.runs).toHaveLength(1);
}); });
it('does nothing when renaming a project that never had a sidecar', async () => { it('keeps writing to the new location after a rename', async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'a' }));
await renameReportsForProject('project-a', 'project-b');
await upsertRun(makeRun({ id: 'later' }));
expect(files.has(runPath('project-b', 'later'))).toBe(true);
expect(files.has(runPath('project-a', 'later'))).toBe(false);
});
it('does nothing when renaming a project that never had reports', async () => {
await expect(renameReportsForProject('never-loaded', 'still-never-loaded')).resolves.toBeUndefined(); await expect(renameReportsForProject('never-loaded', 'still-never-loaded')).resolves.toBeUndefined();
}); });
}); });
@@ -1,32 +1,24 @@
import type { ProjectReports } from 'ontime-types'; import type { ShowRun } from 'ontime-types';
import { is } from '../../utils/is.js'; import { is } from '../../utils/is.js';
/** /**
* Shallow check on the contents of a report sidecar. * Shallow check on the contents of a report file.
* *
* We are the only writer of this file, so this guards against it being * We are the only writer of these files, so this guards against one being
* missing, empty or hand edited rather than against arbitrary payloads. * empty, truncated or hand edited rather than against arbitrary payloads.
* Anything that fails is discarded and the project starts with no history, * A file that fails is skipped, leaving the rest of the history readable.
* which is the same outcome as a first run.
*/ */
export function isProjectReports(value: unknown): value is ProjectReports { export function isShowRun(value: unknown): value is ShowRun {
if (!is.object(value) || !is.objectWithKeys(value, ['runs'])) { if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'endedAt', 'report', 'summary'])) {
return false; return false;
} }
if (!is.array(value.runs)) { return (
return false; is.string(value.id) &&
} is.number(value.startedAt) &&
is.number(value.endedAt) &&
return value.runs.every(isRunShaped); is.object(value.report) &&
} is.object(value.summary)
);
/** Enough of a run to be listed and opened without throwing */
function isRunShaped(value: unknown): boolean {
if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'report', 'summary'])) {
return false;
}
return is.string(value.id) && is.number(value.startedAt) && is.object(value.report) && is.object(value.summary);
} }
@@ -1,86 +1,115 @@
import { join } from 'path'; import { join } from 'path';
import { JSONFile } from 'lowdb/node'; import { JSONFile } from 'lowdb/node';
import type { ProjectReports, ShowRun } from 'ontime-types'; import type { ShowRun } from 'ontime-types';
import { publicDir } from '../../setup/index.js'; import { publicDir } from '../../setup/index.js';
import { deleteFile, dockerSafeRename, ensureJsonExtension, statIfExists } from '../../utils/fileManagement.js'; import {
import { isProjectReports } from './report.parser.js'; deleteDirectory,
deleteFile,
dockerSafeRename,
ensureDirectory,
readDirectoryEntries,
removeFileExtension,
statIfExists,
} from '../../utils/fileManagement.js';
import { isShowRun } from './report.parser.js';
/** /**
* Reports are kept in a sidecar file per project rather than in the project * Reports live in a directory per project, one file per run.
* itself. This keeps the project file free of mid-show writes and lets the *
* run history grow without bloating what the user exports. * Keeping them out of the project file leaves it free of show time writes and
* lets history grow without bloating what the user exports. Keeping each run
* in its own file means finishing a show writes only that show, rather than
* rewriting everything the project has ever recorded.
* *
* Persistence is best effort by design: a failing disk degrades reporting * Persistence is best effort by design: a failing disk degrades reporting
* but must never interrupt a running show. * but must never interrupt a running show.
*/ */
/** /** runs for the loaded project, newest first */
* Returns a fresh empty store. let cache: ShowRun[] = [];
* Must not be a shared constant: `cache.runs` is mutated in place elsewhere let projectDir: string | null = null;
* 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; let failedWriteAttempts = 0;
/** /** Directory holding a project's reports */
* Resolves the sidecar path for a given project file name
*/
export function getPathToReports(projectFilename: string): string { export function getPathToReports(projectFilename: string): string {
return join(publicDir.reportsDir, ensureJsonExtension(projectFilename)); // the project name without its extension, so "show.json" does not read as a file
return join(publicDir.reportsDir, removeFileExtension(projectFilename));
}
function getPathToRun(id: string): string | null {
return projectDir === null ? null : join(projectDir, `${id}.json`);
} }
/** /**
* Points the store at a project's sidecar and loads whatever is on disk. * Points the store at a project's report directory and loads what is there.
* Called on every project load, which is the single choke point for * Called on every project load, which is the single choke point for
* project changes. * project changes.
*/ */
export async function loadReports(projectFilename: string): Promise<ProjectReports> { export async function loadReports(projectFilename: string): Promise<ShowRun[]> {
fileRef = new JSONFile<ProjectReports>(getPathToReports(projectFilename)); const dir = getPathToReports(projectFilename);
projectDir = dir;
failedWriteAttempts = 0; failedWriteAttempts = 0;
cache = [];
try { try {
const maybeReports = await fileRef.read(); const entries = await readDirectoryEntries(dir);
cache = isProjectReports(maybeReports) ? maybeReports : emptyStore(); const reports = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
const contents = await Promise.all(
reports.map(async (entry) => {
try {
return await new JSONFile<unknown>(join(dir, entry.name)).read();
} catch (_error) {
// a single unreadable report is skipped rather than losing the rest
return null;
}
}),
);
// files come off disk in arbitrary order, the list is presented newest first
cache = contents.filter(isShowRun).sort((a, b) => b.startedAt - a.startedAt);
} catch (_error) { } catch (_error) {
// a missing or corrupt sidecar is not worth interrupting a project load over // a missing directory is the normal case for a project with no history
cache = emptyStore();
} }
return cache; return cache;
} }
/** /** Runs held for the current project, newest first */
* Returns the runs held for the current project, newest first
*/
export function getRuns(): ShowRun[] { export function getRuns(): ShowRun[] {
return cache.runs; return cache;
} }
export function getRun(id: string): ShowRun | undefined { export function getRun(id: string): ShowRun | undefined {
return cache.runs.find((run) => run.id === id); return cache.find((run) => run.id === id);
} }
/** /**
* Inserts or replaces a run, keeping the list ordered newest first. * Writes a single finished report.
* * Only this run's file is touched, so the cost does not grow with history.
* Every write here is a whole finished report, which only happens when a run
* is finished or edited. Nothing writes on the cue path.
*/ */
export async function upsertRun(run: ShowRun): Promise<void> { export async function upsertRun(run: ShowRun): Promise<void> {
const index = cache.runs.findIndex((candidate) => candidate.id === run.id); const index = cache.findIndex((candidate) => candidate.id === run.id);
if (index === -1) { if (index === -1) {
cache.runs.unshift(run); cache.unshift(run);
} else { } else {
cache.runs[index] = run; cache[index] = run;
}
const dir = projectDir;
if (dir === null || failedWriteAttempts > 3) {
return;
}
try {
ensureDirectory(dir);
await new JSONFile<ShowRun>(join(dir, `${run.id}.json`)).write(run);
failedWriteAttempts = 0;
} catch (_error) {
failedWriteAttempts += 1;
} }
await persist();
} }
/** /**
@@ -88,13 +117,13 @@ export async function upsertRun(run: ShowRun): Promise<void> {
* @returns whether a run was found and removed * @returns whether a run was found and removed
*/ */
export async function deleteRun(id: string): Promise<boolean> { export async function deleteRun(id: string): Promise<boolean> {
const index = cache.runs.findIndex((run) => run.id === id); const index = cache.findIndex((run) => run.id === id);
if (index === -1) { if (index === -1) {
return false; return false;
} }
cache.runs.splice(index, 1); cache.splice(index, 1);
await persist(); await removeRunFile(id);
return true; return true;
} }
@@ -103,42 +132,42 @@ export async function deleteRun(id: string): Promise<boolean> {
* @returns how many runs were removed * @returns how many runs were removed
*/ */
export async function deleteRunsForRundown(rundownId: string): Promise<number> { export async function deleteRunsForRundown(rundownId: string): Promise<number> {
const before = cache.runs.length; const doomed = cache.filter((run) => run.rundownId === rundownId);
cache.runs = cache.runs.filter((run) => run.rundownId !== rundownId); cache = cache.filter((run) => run.rundownId !== rundownId);
const removed = before - cache.runs.length; // separate files, so these can go at once
if (removed > 0) { await Promise.all(doomed.map((run) => removeRunFile(run.id)));
await persist(); return doomed.length;
}
return removed;
} }
/** /** Clears the run history of the current project */
* Clears the run history of the current project
*/
export async function deleteAllRuns(): Promise<void> { export async function deleteAllRuns(): Promise<void> {
cache.runs = []; cache = [];
await persist(); if (projectDir === null) {
return;
}
try {
await deleteDirectory(projectDir);
} catch (_error) {
// leftovers are harmless, they are filtered on next load
}
} }
/** /**
* Removes a project's sidecar from disk. * Removes a project's reports.
* Reports are owned by their project and do not outlive it. * They are owned by the project and do not outlive it, so this is a single
* recursive delete of its directory.
*/ */
export async function deleteReportsForProject(projectFilename: string): Promise<void> { export async function deleteReportsForProject(projectFilename: string): Promise<void> {
const path = getPathToReports(projectFilename);
try { try {
if ((await statIfExists(path)) !== null) { await deleteDirectory(getPathToReports(projectFilename));
await deleteFile(path);
}
} catch (_error) { } catch (_error) {
// a leftover sidecar is harmless, deleting the project must still succeed // a leftover directory is harmless, deleting the project must still succeed
} }
} }
/** /** Moves a project's reports so history follows a project rename */
* Moves a project's sidecar so run history follows a project rename
*/
export async function renameReportsForProject(originalFilename: string, newFilename: string): Promise<void> { export async function renameReportsForProject(originalFilename: string, newFilename: string): Promise<void> {
const originalPath = getPathToReports(originalFilename); const originalPath = getPathToReports(originalFilename);
const newPath = getPathToReports(newFilename); const newPath = getPathToReports(newFilename);
@@ -148,38 +177,33 @@ export async function renameReportsForProject(originalFilename: string, newFilen
return; return;
} }
await dockerSafeRename(originalPath, newPath); await dockerSafeRename(originalPath, newPath);
// keep the reference pointing at the file we just moved if (projectDir === originalPath) {
if (fileRef) { projectDir = newPath;
fileRef = new JSONFile<ProjectReports>(newPath);
} }
} catch (_error) { } catch (_error) {
// losing history on rename is bad but not fatal, the project rename stands // losing history on rename is bad but not fatal, the project rename stands
} }
} }
/** /** @private */
* Writes the cache to disk. async function removeRunFile(id: string): Promise<void> {
* Gives up after repeated failures so a broken disk cannot stall the runtime. const path = getPathToRun(id);
* @private if (path === null) {
*/
async function persist(): Promise<void> {
if (fileRef === null || failedWriteAttempts > 3) {
return; return;
} }
try { try {
await fileRef.write(cache); if ((await statIfExists(path)) !== null) {
failedWriteAttempts = 0; await deleteFile(path);
}
} catch (_error) { } catch (_error) {
failedWriteAttempts += 1; // the run is already out of the cache, a stray file is filtered on load
} }
} }
/** /** Resets in-memory state, used when no project is loaded and in tests */
* Resets in-memory state, used when no project is loaded and in tests
*/
export function resetStore(): void { export function resetStore(): void {
fileRef = null; projectDir = null;
cache = emptyStore(); cache = [];
failedWriteAttempts = 0; failedWriteAttempts = 0;
} }
@@ -17,7 +17,7 @@ import {
import { millisToString, validatePlayback } from 'ontime-utils'; import { millisToString, validatePlayback } from 'ontime-utils';
import { triggerAutomations } from '../../api-data/automation/automation.service.js'; 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 { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
@@ -523,6 +523,10 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => { process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState); triggerReportEntry(TimerLifeCycle.onStop, previousState);
// stop is what the Go button does on the last event, where it reads
// "Finish". Ending playback is the operator ending the show, and that
// is what turns the run in progress into a report.
void closeRun();
triggerAutomations(TimerLifeCycle.onStop); triggerAutomations(TimerLifeCycle.onStop);
}); });
@@ -67,8 +67,3 @@ export type ShowRunSummary = Omit<ShowRun, 'report'>;
* and has no end. * and has no end.
*/ */
export type OpenRun = Omit<ShowRun, 'report' | 'summary' | 'endedAt'>; export type OpenRun = Omit<ShowRun, 'report' | 'summary' | 'endedAt'>;
/** Contents of a project's report sidecar file */
export type ProjectReports = {
runs: ShowRun[];
};
-1
View File
@@ -28,7 +28,6 @@ export type {
OntimeReport, OntimeReport,
OntimeEventReport, OntimeEventReport,
OpenRun, OpenRun,
ProjectReports,
RunSummary, RunSummary,
ShowRun, ShowRun,
ShowRunSummary, ShowRunSummary,