mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 02:13:48 +00:00
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:
@@ -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> {
|
||||
const res = await axios.patch(`${reportUrl}/runs/${id}`, { label });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
.indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
white-space: nowrap;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
|
||||
&:hover {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
||||
@@ -1,58 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
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 { useOpenRun } from '../../../common/hooks-query/useRuns';
|
||||
|
||||
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
|
||||
* open, so it stays out of the way until it is relevant, and finishing here
|
||||
* is what writes the run to history.
|
||||
* This is how the feature is discovered: it appears with the first event and
|
||||
* points at the reports panel. Ending the run is the existing Finish action
|
||||
* on the last event, so there is no separate control here.
|
||||
*/
|
||||
export default function RunIndicator() {
|
||||
const openRun = useOpenRun();
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!openRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleFinish = async () => {
|
||||
setIsFinishing(true);
|
||||
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);
|
||||
}
|
||||
};
|
||||
// startedAt is a wall clock instant, not a time of day, so formatTime does not apply
|
||||
const since = new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' });
|
||||
|
||||
return (
|
||||
<div className={style.indicator}>
|
||||
<Tooltip text='A report is being recorded for this show' render={<span />}>
|
||||
<span className={style.label}>
|
||||
<span className={style.dot} />
|
||||
{/* startedAt is a wall clock instant, not a time of day, so it is not formatTime's job */}
|
||||
Recording since{' '}
|
||||
<span className={style.since}>
|
||||
{new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' })}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button size='small' variant='subtle' onClick={handleFinish} disabled={isFinishing}>
|
||||
Finish run
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip text='A report is being recorded. It is saved when you finish the show.' render={<span />}>
|
||||
<button
|
||||
type='button'
|
||||
className={style.indicator}
|
||||
onClick={() => navigate('/editor?settings=sharing__report')}
|
||||
aria-label='Show reports'
|
||||
>
|
||||
<span className={style.dot} />
|
||||
Recording since <span className={style.since}>{since}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ let writes = 0;
|
||||
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 })),
|
||||
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) => {
|
||||
|
||||
@@ -37,18 +37,6 @@ router.get('/runs/open', (_req: Request, res: Response) => {
|
||||
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) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import type { ShowRun } from 'ontime-types';
|
||||
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>();
|
||||
|
||||
vi.mock('lowdb/node', () => {
|
||||
@@ -24,18 +26,35 @@ vi.mock('../../../utils/fileManagement.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils/fileManagement.js')>(
|
||||
'../../../utils/fileManagement.js',
|
||||
);
|
||||
|
||||
const childrenOf = (dir: string) => [...files.keys()].filter((path) => path.startsWith(`${dir}/`));
|
||||
|
||||
return {
|
||||
...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) => {
|
||||
files.delete(path);
|
||||
}),
|
||||
dockerSafeRename: vi.fn(async (oldPath: string, newPath: string) => {
|
||||
if (files.has(oldPath)) {
|
||||
files.set(newPath, files.get(oldPath));
|
||||
files.delete(oldPath);
|
||||
deleteDirectory: vi.fn(async (dir: string) => {
|
||||
for (const path of childrenOf(dir)) files.delete(path);
|
||||
}),
|
||||
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',
|
||||
rundownId: 'rundown-1',
|
||||
rundownTitle: 'My rundown',
|
||||
label: '2026-08-08',
|
||||
label: '8 Aug 2026, 09:30',
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
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(() => {
|
||||
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([]);
|
||||
it('starts empty for a project with no reports', async () => {
|
||||
expect(await loadReports('project-a')).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('resolves a directory per project, ignoring the file extension', async () => {
|
||||
expect(getPathToReports('my show.json')).toBe(getPathToReports('my show'));
|
||||
});
|
||||
|
||||
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('loads every run in the project directory', async () => {
|
||||
files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
|
||||
files.set(runPath('project-a', 'b'), makeRun({ id: 'b' }));
|
||||
|
||||
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 () => {
|
||||
files.set(getPathToReports('project-a'), { runs: [makeRun({ id: 'a' })] });
|
||||
files.set(getPathToReports('project-b'), { runs: [makeRun({ id: 'b' })] });
|
||||
files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
|
||||
files.set(runPath('project-b', 'b'), makeRun({ id: 'b' }));
|
||||
|
||||
await loadReports('project-a');
|
||||
expect(getRuns().map((run) => run.id)).toEqual(['a']);
|
||||
@@ -114,14 +154,18 @@ describe('loadReports()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertRun() / getRun() / getRuns()', () => {
|
||||
describe('upsertRun()', () => {
|
||||
beforeEach(async () => {
|
||||
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: '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']);
|
||||
});
|
||||
|
||||
@@ -133,15 +177,12 @@ describe('upsertRun() / getRun() / getRuns()', () => {
|
||||
expect(getRun('run-1')?.label).toBe('renamed');
|
||||
});
|
||||
|
||||
it('persists to disk', async () => {
|
||||
it('survives a reload', async () => {
|
||||
await upsertRun(makeRun());
|
||||
const reloaded = await loadReports('project-a');
|
||||
expect(reloaded.runs).toHaveLength(1);
|
||||
expect(await loadReports('project-a')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe('deleteRun()', () => {
|
||||
beforeEach(async () => {
|
||||
await loadReports('project-a');
|
||||
@@ -149,10 +190,12 @@ describe('deleteRun()', () => {
|
||||
await upsertRun(makeRun({ id: 'discard' }));
|
||||
});
|
||||
|
||||
it('removes only the targeted run', async () => {
|
||||
const didDelete = await deleteRun('discard');
|
||||
expect(didDelete).toBe(true);
|
||||
it('removes only the targeted run and its file', async () => {
|
||||
expect(await deleteRun('discard')).toBe(true);
|
||||
|
||||
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 () => {
|
||||
@@ -168,48 +211,65 @@ describe('deleteRunsForRundown()', () => {
|
||||
await upsertRun(makeRun({ id: 'b', rundownId: 'rundown-y' }));
|
||||
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(files.has(runPath('project-a', 'a'))).toBe(false);
|
||||
expect(files.has(runPath('project-a', 'c'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllRuns()', () => {
|
||||
it('empties the run history', async () => {
|
||||
it('empties the project directory', async () => {
|
||||
await loadReports('project-a');
|
||||
await upsertRun(makeRun());
|
||||
await upsertRun(makeRun({ id: 'a' }));
|
||||
await upsertRun(makeRun({ id: 'b' }));
|
||||
|
||||
await deleteAllRuns();
|
||||
|
||||
expect(getRuns()).toEqual([]);
|
||||
expect(files.has(runPath('project-a', 'a'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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 upsertRun(makeRun());
|
||||
expect(files.has(getPathToReports('project-a'))).toBe(true);
|
||||
await upsertRun(makeRun({ id: 'a' }));
|
||||
await upsertRun(makeRun({ id: 'b' }));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 upsertRun(makeRun());
|
||||
await upsertRun(makeRun({ id: 'a' }));
|
||||
|
||||
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);
|
||||
expect(files.has(runPath('project-a', 'a'))).toBe(false);
|
||||
expect(files.has(runPath('project-b', 'a'))).toBe(true);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
import type { ProjectReports } from 'ontime-types';
|
||||
import type { ShowRun } from 'ontime-types';
|
||||
|
||||
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
|
||||
* missing, empty or hand edited rather than against arbitrary payloads.
|
||||
* Anything that fails is discarded and the project starts with no history,
|
||||
* which is the same outcome as a first run.
|
||||
* We are the only writer of these files, so this guards against one being
|
||||
* empty, truncated or hand edited rather than against arbitrary payloads.
|
||||
* A file that fails is skipped, leaving the rest of the history readable.
|
||||
*/
|
||||
export function isProjectReports(value: unknown): value is ProjectReports {
|
||||
if (!is.object(value) || !is.objectWithKeys(value, ['runs'])) {
|
||||
export function isShowRun(value: unknown): value is ShowRun {
|
||||
if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'endedAt', 'report', 'summary'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.array(value.runs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.runs.every(isRunShaped);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return (
|
||||
is.string(value.id) &&
|
||||
is.number(value.startedAt) &&
|
||||
is.number(value.endedAt) &&
|
||||
is.object(value.report) &&
|
||||
is.object(value.summary)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,115 @@
|
||||
import { join } from 'path';
|
||||
|
||||
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 { deleteFile, dockerSafeRename, ensureJsonExtension, statIfExists } from '../../utils/fileManagement.js';
|
||||
import { isProjectReports } from './report.parser.js';
|
||||
import {
|
||||
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
|
||||
* itself. This keeps the project file free of mid-show writes and lets the
|
||||
* run history grow without bloating what the user exports.
|
||||
* Reports live in a directory per project, one file per run.
|
||||
*
|
||||
* 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
|
||||
* 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();
|
||||
/** runs for the loaded project, newest first */
|
||||
let cache: ShowRun[] = [];
|
||||
let projectDir: string | null = null;
|
||||
let failedWriteAttempts = 0;
|
||||
|
||||
/**
|
||||
* Resolves the sidecar path for a given project file name
|
||||
*/
|
||||
/** Directory holding a project's reports */
|
||||
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
|
||||
* project changes.
|
||||
*/
|
||||
export async function loadReports(projectFilename: string): Promise<ProjectReports> {
|
||||
fileRef = new JSONFile<ProjectReports>(getPathToReports(projectFilename));
|
||||
export async function loadReports(projectFilename: string): Promise<ShowRun[]> {
|
||||
const dir = getPathToReports(projectFilename);
|
||||
projectDir = dir;
|
||||
failedWriteAttempts = 0;
|
||||
cache = [];
|
||||
|
||||
try {
|
||||
const maybeReports = await fileRef.read();
|
||||
cache = isProjectReports(maybeReports) ? maybeReports : emptyStore();
|
||||
const entries = await readDirectoryEntries(dir);
|
||||
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) {
|
||||
// a missing or corrupt sidecar is not worth interrupting a project load over
|
||||
cache = emptyStore();
|
||||
// a missing directory is the normal case for a project with no history
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the runs held for the current project, newest first
|
||||
*/
|
||||
/** Runs held for the current project, newest first */
|
||||
export function getRuns(): ShowRun[] {
|
||||
return cache.runs;
|
||||
return cache;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Every write here is a whole finished report, which only happens when a run
|
||||
* is finished or edited. Nothing writes on the cue path.
|
||||
* Writes a single finished report.
|
||||
* Only this run's file is touched, so the cost does not grow with history.
|
||||
*/
|
||||
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) {
|
||||
cache.runs.unshift(run);
|
||||
cache.unshift(run);
|
||||
} 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
|
||||
*/
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cache.runs.splice(index, 1);
|
||||
await persist();
|
||||
cache.splice(index, 1);
|
||||
await removeRunFile(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -103,42 +132,42 @@ export async function deleteRun(id: string): Promise<boolean> {
|
||||
* @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 doomed = cache.filter((run) => run.rundownId === rundownId);
|
||||
cache = cache.filter((run) => run.rundownId !== rundownId);
|
||||
|
||||
const removed = before - cache.runs.length;
|
||||
if (removed > 0) {
|
||||
await persist();
|
||||
}
|
||||
return removed;
|
||||
// separate files, so these can go at once
|
||||
await Promise.all(doomed.map((run) => removeRunFile(run.id)));
|
||||
return doomed.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the run history of the current project
|
||||
*/
|
||||
/** Clears the run history of the current project */
|
||||
export async function deleteAllRuns(): Promise<void> {
|
||||
cache.runs = [];
|
||||
await persist();
|
||||
cache = [];
|
||||
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.
|
||||
* Reports are owned by their project and do not outlive it.
|
||||
* Removes a project's reports.
|
||||
* 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> {
|
||||
const path = getPathToReports(projectFilename);
|
||||
try {
|
||||
if ((await statIfExists(path)) !== null) {
|
||||
await deleteFile(path);
|
||||
}
|
||||
await deleteDirectory(getPathToReports(projectFilename));
|
||||
} 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 sidecar so run history follows a project rename
|
||||
*/
|
||||
/** Moves a project's reports so history follows a project rename */
|
||||
export async function renameReportsForProject(originalFilename: string, newFilename: string): Promise<void> {
|
||||
const originalPath = getPathToReports(originalFilename);
|
||||
const newPath = getPathToReports(newFilename);
|
||||
@@ -148,38 +177,33 @@ export async function renameReportsForProject(originalFilename: string, newFilen
|
||||
return;
|
||||
}
|
||||
await dockerSafeRename(originalPath, newPath);
|
||||
// keep the reference pointing at the file we just moved
|
||||
if (fileRef) {
|
||||
fileRef = new JSONFile<ProjectReports>(newPath);
|
||||
if (projectDir === originalPath) {
|
||||
projectDir = 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) {
|
||||
/** @private */
|
||||
async function removeRunFile(id: string): Promise<void> {
|
||||
const path = getPathToRun(id);
|
||||
if (path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fileRef.write(cache);
|
||||
failedWriteAttempts = 0;
|
||||
if ((await statIfExists(path)) !== null) {
|
||||
await deleteFile(path);
|
||||
}
|
||||
} 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 {
|
||||
fileRef = null;
|
||||
cache = emptyStore();
|
||||
projectDir = null;
|
||||
cache = [];
|
||||
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,10 @@ class RuntimeService {
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
process.nextTick(() => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -67,8 +67,3 @@ export type ShowRunSummary = Omit<ShowRun, 'report'>;
|
||||
* and has no end.
|
||||
*/
|
||||
export type OpenRun = Omit<ShowRun, 'report' | 'summary' | 'endedAt'>;
|
||||
|
||||
/** Contents of a project's report sidecar file */
|
||||
export type ProjectReports = {
|
||||
runs: ShowRun[];
|
||||
};
|
||||
|
||||
@@ -28,7 +28,6 @@ export type {
|
||||
OntimeReport,
|
||||
OntimeEventReport,
|
||||
OpenRun,
|
||||
ProjectReports,
|
||||
RunSummary,
|
||||
ShowRun,
|
||||
ShowRunSummary,
|
||||
|
||||
Reference in New Issue
Block a user