mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 19:03:47 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| beb0a1394d | |||
| c6eccec30e | |||
| 5220c2c374 | |||
| 4eeeb294f7 |
@@ -200,8 +200,7 @@ $card-padding: 2rem;
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: $zindex-backdrop;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.updateIndicator {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AppVersion.module.scss';
|
||||
|
||||
export default function AppVersion() {
|
||||
const { data, isError } = useAppVersion();
|
||||
|
||||
@@ -18,7 +20,12 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={`Ontime ${appVersion}`}
|
||||
title={
|
||||
<>
|
||||
<span className={style.updateIndicator} aria-hidden='true' />
|
||||
{`Ontime ${appVersion}`}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
@@ -26,7 +33,7 @@ export default function AppVersion() {
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
);
|
||||
|
||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{data.pendingRestart && (
|
||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
||||
submenu: [
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
||||
click: () => redirectWindow('/editor?settings=project__create'),
|
||||
},
|
||||
{
|
||||
label: 'Load...',
|
||||
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||
},
|
||||
{
|
||||
label: 'Custom views',
|
||||
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||
},
|
||||
{
|
||||
label: 'Server port',
|
||||
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
||||
|
||||
/**
|
||||
* Wraps the app authenticate middleware with support for the Authorization header.
|
||||
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
||||
* rather than cookies or query params; any other request falls through to the
|
||||
* app middleware, keeping the behaviour of the shared middleware untouched.
|
||||
*/
|
||||
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
||||
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (hasPassword) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
return fallback(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
import { integrationRouter } from './api-integration/integration.router.js';
|
||||
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
// Services
|
||||
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
||||
|
||||
// serve static external files
|
||||
app.use(
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
|
||||
import { isPublicAssetRequest } from '../authenticate.js';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../api-data/session/session.service.js', () => ({
|
||||
hasPassword: true,
|
||||
hashedPassword: 'valid-token',
|
||||
}));
|
||||
|
||||
import {
|
||||
authenticateSocket,
|
||||
isPublicAssetRequest,
|
||||
makeAuthenticateMiddleware,
|
||||
} from '../authenticate.js';
|
||||
|
||||
function makeResponse() {
|
||||
return {
|
||||
redirect: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function makeHeadersWithFailingAuthorization(cookie?: string) {
|
||||
return {
|
||||
cookie,
|
||||
get authorization(): never {
|
||||
throw new Error('Authorization header should not be read');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('isPublicAssetRequest()', () => {
|
||||
it('allows root public assets without a prefix', () => {
|
||||
@@ -18,3 +47,102 @@ describe('isPublicAssetRequest()', () => {
|
||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer authentication', () => {
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockClear();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for API requests', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||
headers: makeHeadersWithFailingAuthorization(),
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for redirecting routes', () => {
|
||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||
headers: makeHeadersWithFailingAuthorization(),
|
||||
originalUrl: '/external/image.png',
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for WebSocket handshakes', () => {
|
||||
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
|
||||
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
|
||||
|
||||
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('authenticates API requests with a bearer token', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authenticates redirecting routes with a bearer token', () => {
|
||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
originalUrl: '/stage/external/image.png',
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticateAndRedirect(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authenticates WebSocket handshakes with a bearer token', () => {
|
||||
const req = {
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
} as IncomingMessage;
|
||||
|
||||
authenticateSocket({} as never, req, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('rejects an invalid bearer token', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,17 +77,16 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
||||
const loginRedirectBase = `${prefix}/login?redirect=`;
|
||||
|
||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.query.token) {
|
||||
if (req.query.token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.cookies?.token) {
|
||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||
if (tokenFromCookie === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(401).send('Unauthorized');
|
||||
@@ -105,17 +104,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// we expect the token to be in the cookies
|
||||
if (req.cookies?.token) {
|
||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||
if (tokenFromCookie === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// we use query params for generating authenticated URLs and for clients like the companion module
|
||||
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
||||
if (req.query.token === hashedPassword) {
|
||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||
if (hashedPassword !== undefined) {
|
||||
setSessionCookie(res, hashedPassword, prefix);
|
||||
}
|
||||
@@ -136,33 +135,16 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
||||
return next();
|
||||
}
|
||||
|
||||
// check if the token is in the cookie
|
||||
const cookieString = req.headers.cookie;
|
||||
if (typeof cookieString === 'string') {
|
||||
const cookies = parseCookie(cookieString);
|
||||
if (cookies.token) {
|
||||
const token = getTokenFromCookie(cookies.token);
|
||||
if (token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if token is in the params - simple string check first
|
||||
const urlString = req.url || '';
|
||||
if (urlString.includes(`token=${hashedPassword}`)) {
|
||||
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// fallback to full URL parsing for other formats
|
||||
try {
|
||||
const url = new URL(urlString, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
if (token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore URL parsing errors
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return next(new Error('Unauthorized'));
|
||||
@@ -181,19 +163,18 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When calling this function we already know a cookie called 'token' exists
|
||||
* And want to extract its value
|
||||
*/
|
||||
function getTokenFromCookie(cookieContents: string): string | undefined {
|
||||
// Fast path: check if the hashed password is directly in the cookie string
|
||||
// This avoids JSON parsing for the common case
|
||||
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
|
||||
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
|
||||
if (typeof cookieContents !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fast path: avoid JSON parsing when the expected token can be found directly
|
||||
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
||||
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
// Fallback to JSON parsing for other cases or validation
|
||||
try {
|
||||
const cookie = JSON.parse(cookieContents);
|
||||
if (cookie && typeof cookie.token === 'string') {
|
||||
@@ -203,3 +184,29 @@ function getTokenFromCookie(cookieContents: string): string | undefined {
|
||||
// no error handling to do here
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
|
||||
if (authorization?.startsWith('Bearer ')) {
|
||||
return authorization.slice(7);
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromParams(
|
||||
params: string | Record<string, unknown> | undefined,
|
||||
host?: string,
|
||||
): string | undefined {
|
||||
if (typeof params !== 'string') {
|
||||
return typeof params?.token === 'string' ? params.token : undefined;
|
||||
}
|
||||
|
||||
// Fast path for WebSocket URLs
|
||||
if (params.includes(`token=${hashedPassword}`)) {
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import * as timeCore from '../../lib/time-core/timeCore.js';
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
@@ -54,12 +53,11 @@ describe('getElapsed()', () => {
|
||||
it('uses the current pause start while paused', () => {
|
||||
const state = {
|
||||
clock: 10 * MILLIS_PER_MINUTE,
|
||||
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
timer: {
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
pausedAt: 7 * MILLIS_PER_MINUTE,
|
||||
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
} as RuntimeState;
|
||||
@@ -977,40 +975,6 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(25);
|
||||
});
|
||||
|
||||
it('paused time is delayed time when the pause spans midnight', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
|
||||
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
timer: {
|
||||
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
|
||||
current: 25, // still counting down
|
||||
addedTime: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: timeCore.toInstant(
|
||||
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
|
||||
(timeCore.now() - dayInMs) as Instant,
|
||||
), // 23:58, before midnight
|
||||
pausedDuration: 0,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 23 * MILLIS_PER_HOUR,
|
||||
plannedStart: 23 * MILLIS_PER_HOUR,
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
|
||||
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
|
||||
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('offset doesnt exist if we havent started', () => {
|
||||
const state = {
|
||||
clock: 78480789,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import * as timeCore from '../lib/time-core/timeCore.js';
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
|
||||
/**
|
||||
@@ -97,18 +96,17 @@ export function getCurrent(state: RuntimeState): number {
|
||||
* Calculates active time elapsed since the timer started.
|
||||
*/
|
||||
export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||
const { clock, _now } = state;
|
||||
const { clock } = state;
|
||||
const { startedAt } = state.timer;
|
||||
const { pausedDuration, pausedAt } = state._timer;
|
||||
const { pausedAt, pausedDuration } = state._timer;
|
||||
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
|
||||
|
||||
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
|
||||
const referenceClock = pausedAt ?? clock;
|
||||
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration;
|
||||
|
||||
return Math.max(0, activeElapsed);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../runtimeState.js';
|
||||
|
||||
const baseState: RuntimeState = {
|
||||
clock: 0 as TimeOfDay,
|
||||
_now: 0 as Instant,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
eventFlag: null,
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
|
||||
playback: Playback.Pause,
|
||||
addedTime: 0,
|
||||
});
|
||||
expect(newState._timer.pausedAt).toEqual(newState._now);
|
||||
expect(newState._timer.pausedAt).toEqual(newState.clock);
|
||||
|
||||
success = pause();
|
||||
expect(success).toBe(false);
|
||||
@@ -248,59 +248,6 @@ describe('mutation on runtimeState', () => {
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
test('elapsed excludes a pause that spans midnight', async () => {
|
||||
clearState();
|
||||
// an event that runs over midnight (23:00 -> 01:00)
|
||||
const event = {
|
||||
...mockEvent,
|
||||
id: 'elapsed-pause-midnight',
|
||||
timeStart: 23 * MILLIS_PER_HOUR,
|
||||
timeEnd: 1 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
};
|
||||
const mockRundown = makeRundown({
|
||||
entries: { [event.id]: event },
|
||||
order: [event.id],
|
||||
});
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start before midnight
|
||||
vi.setSystemTime('jan 1 23:50');
|
||||
load(event, rundown, metadata);
|
||||
start();
|
||||
|
||||
// 8 minutes of active running before we pause
|
||||
vi.setSystemTime('jan 1 23:58');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
pause();
|
||||
|
||||
// elapsed is active time since start, so it must not advance while paused,
|
||||
// not even when the pause itself crosses midnight
|
||||
vi.setSystemTime('jan 2 00:01');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
|
||||
vi.setSystemTime('jan 2 00:03');
|
||||
start();
|
||||
let state = getState();
|
||||
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
|
||||
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
|
||||
// and elapsed still reflects only the 8 active minutes
|
||||
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// 2 more active minutes after resume -> 10 minutes elapsed
|
||||
vi.setSystemTime('jan 2 00:05');
|
||||
update();
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime offset', async () => {
|
||||
|
||||
@@ -63,9 +63,7 @@ export type RuntimeState = {
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||
pausedAt: Maybe<Instant>;
|
||||
|
||||
/** Accumulate pause duration but dose not include the current pause */
|
||||
pausedAt: Maybe<TimeOfDay>;
|
||||
pausedDuration: number;
|
||||
secondaryTarget: Maybe<TimeOfDay>;
|
||||
hasFinished: boolean;
|
||||
@@ -78,12 +76,10 @@ export type RuntimeState = {
|
||||
_end: ExpectedMetadata;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
_startDayOffset: Maybe<Day>;
|
||||
_now: Instant;
|
||||
};
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
clock: timeCore.timeOfDayNow(),
|
||||
_now: timeCore.now(),
|
||||
groupNow: null,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
@@ -108,12 +104,6 @@ const runtimeState: RuntimeState = {
|
||||
_startDayOffset: null,
|
||||
};
|
||||
|
||||
/** set the current clock to ensure parity between _now and clock */
|
||||
function setClock(state: RuntimeState) {
|
||||
state._now = timeCore.now();
|
||||
state.clock = timeCore.toTimeOfDay(state._now);
|
||||
}
|
||||
|
||||
export function getState(): Readonly<RuntimeState> {
|
||||
// create a shallow copy of the state
|
||||
return {
|
||||
@@ -146,7 +136,7 @@ export function clearEventData() {
|
||||
runtimeState.rundown.selectedEventIndex = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
setClock(runtimeState);
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
@@ -179,7 +169,7 @@ export function clearState() {
|
||||
runtimeState._end = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
setClock(runtimeState);
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
@@ -432,12 +422,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
setClock(state);
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
|
||||
state.clock = now;
|
||||
state.timer.secondaryTimer = null;
|
||||
|
||||
// add paused time if it exists
|
||||
if (state._timer.pausedAt) {
|
||||
const timeToAdd = state._now - state._timer.pausedAt;
|
||||
const timeToAdd = state.clock - state._timer.pausedAt;
|
||||
state.timer.addedTime += timeToAdd;
|
||||
state._timer.pausedDuration += timeToAdd;
|
||||
state._timer.pausedAt = null;
|
||||
@@ -454,7 +447,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
if (state.rundown.actualStart === null) {
|
||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||
state.rundown.currentDay = state._startDayOffset;
|
||||
state._startEpoch = state._now;
|
||||
state._startEpoch = epoch;
|
||||
state.rundown.actualStart = state.clock;
|
||||
}
|
||||
|
||||
@@ -488,8 +481,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
|
||||
}
|
||||
|
||||
state.timer.playback = Playback.Pause;
|
||||
setClock(state);
|
||||
state._timer.pausedAt = state._now;
|
||||
state.clock = timeCore.timeOfDayNow();
|
||||
state._timer.pausedAt = state.clock;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -554,7 +547,9 @@ export type UpdateResult = {
|
||||
export function update(): UpdateResult {
|
||||
// 0. there are some things we always do
|
||||
const previousClock = runtimeState.clock;
|
||||
setClock(runtimeState); // we update the clock on every update call
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
runtimeState.clock = now; // we update the clock on every update call
|
||||
|
||||
// 1. is playback idle?
|
||||
if (!isPlaybackActive(runtimeState.timer.playback)) {
|
||||
@@ -563,13 +558,13 @@ export function update(): UpdateResult {
|
||||
|
||||
// calculate currentDay from epoch (days elapsed since playback was started)
|
||||
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
|
||||
}
|
||||
|
||||
// 2. are we waiting to roll?
|
||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
|
||||
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user