Compare commits

...

38 Commits

Author SHA1 Message Date
Carlos Valente 2ef0b81743 refactor: extract midnight cross rules 2026-03-03 20:48:53 +01:00
Carlos Valente 5a5450d7ef refactor: use Day type in client 2026-03-03 20:48:53 +01:00
Carlos Valente 6f1f10ccff fix: backdate rundown start when rolling 2026-03-03 20:48:53 +01:00
Carlos Valente 5ff7861968 fix: use rundown day for calculations 2026-03-03 20:48:53 +01:00
Carlos Valente f1a1c358bc fix: infer day offset from epoch 2026-03-03 20:48:53 +01:00
Carlos Valente efd19353e9 fix: roll resets day 2026-03-03 20:48:53 +01:00
Carlos Valente a29a493678 fix: add current day when ticking over midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 27b179276d fix: skip handles events across midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 155cf48a17 fix: roll loads events that cross midnight 2026-03-03 20:48:53 +01:00
Alex Christoffer Rasmussen 358ad79ae4 Automation should use event store for data (#1962)
* fix: automation should use RuntimeStore for data

* add: aux timer template help
2026-03-02 20:54:46 +01:00
Alex Christoffer Rasmussen c1fcdf7065 Remove serverport from project file (#1957)
* feat: get server port from app satate or env

optional startup port from env will always override

function for parsing port from env

populate default port in app state

add test for migration

* bump version
2026-03-02 06:02:29 -08:00
Carlos Valente 1fe58e21be fix: onStart triggered by pending roll start 2026-02-28 10:32:19 +01:00
Carlos Valente 3c0e7ba4d5 chore: fix import across packages 2026-02-27 20:32:02 +01:00
Carlos Valente e8f159d894 feat: create branded types to determine different handling of time 2026-02-27 20:32:02 +01:00
Carlos Valente 168d7103b1 fix: prevent settings reflow on small width screens 2026-02-23 20:20:07 +01:00
Carlos Valente 63d7250a70 refactor: consistent focus styling 2026-02-23 20:19:49 +01:00
Carlos Valente 88f21e2186 refactor: improve timer styling in playback controls 2026-02-23 20:19:49 +01:00
Carlos Valente d9a18b0c42 fix: path comparison considers core params 2026-02-23 15:23:49 +01:00
Carlos Valente df588b8845 refactor: extract cuesheet permissions 2026-02-23 15:23:49 +01:00
Carlos Valente 5be504ce8f fix: prevent presets losing params 2026-02-23 15:23:49 +01:00
Carlos Valente 6268e327b9 refactor: mask cuesheet presets 2026-02-23 15:23:49 +01:00
Carlos Valente 1c81b3a23c refactor: hide edit button for users with no write permissions 2026-02-23 15:23:49 +01:00
Carlos Valente d28895ce9c refactor: prevent import map collision across stages 2026-02-23 15:22:56 +01:00
Carlos Valente 1a23287863 refactor: osc disabled by default to prevent collisions 2026-02-17 21:23:34 +01:00
Alex Christoffer Rasmussen 0b3ba65df3 feat: allow existing custom fields to keep its type on excel import (#1976) 2026-02-17 07:59:27 +08:00
Carlos Valente c3a7cd6a39 bump version to 4.4.2 2026-02-16 06:58:08 +01:00
Carlos Valente 39e6c15adf fix: hide now and next cards if there is no content 2026-02-16 06:58:08 +01:00
Carlos Valente aec6673b07 refactor: improve connection handling
- only invalidate caches on reconnect
- add jitter to avoid overwhelming server and allow cache to stabilise
- prevent multiple reconnect timers
2026-02-14 12:08:04 +01:00
Carlos Valente 9e8cb1c755 refactor: improve request cancellation and timeout handling 2026-02-14 12:08:04 +01:00
Carlos Valente cdf02f7c3f bump version to 4.4.1 2026-02-14 12:07:45 +01:00
Carlos Valente 77779a4648 refactor: debounce writing to disk 2026-02-14 09:30:24 +01:00
Carlos Valente e16ec3f388 refactor: prevent suspending page on first add 2026-02-14 08:56:16 +01:00
Carlos Valente 63cfc9ec19 refactor: optimistic add elements in UI 2026-02-14 08:56:16 +01:00
Carlos Valente 4827117163 refactor: extract entry creation logic 2026-02-14 08:56:16 +01:00
Carlos Valente 5777ef3532 refactor: extract parent resolution 2026-02-14 08:56:16 +01:00
Carlos Valente 80a3b04493 refactor: export rundown placement logic 2026-02-14 08:56:16 +01:00
Carlos Valente 7c5fdd3b19 refactor: remove unused exports 2026-02-14 08:56:16 +01:00
Carlos Valente 31fd8e5200 fix: prevent infinite render in settings forms 2026-02-13 21:12:29 +01:00
144 changed files with 3052 additions and 1110 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.4.0",
"version": "4.5.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.4.0",
"version": "4.5.0",
"private": true,
"type": "module",
"dependencies": {
+3
View File
@@ -3,6 +3,9 @@ import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// apply global axios config defaults
import './common/api/axios.config';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
+5 -4
View File
@@ -4,14 +4,15 @@ import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
return res.data;
}
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -9,14 +9,15 @@ import type {
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath, { signal: options?.signal });
return res.data;
}
+4 -1
View File
@@ -1,5 +1,8 @@
import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304;
return status >= 200 && status < 300;
};
axios.defaults.timeout = axiosConfig.shortTimeout;
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath, { signal: options?.signal });
return res.data;
}
+9 -3
View File
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename });
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
}
/**
@@ -37,6 +39,7 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -76,8 +79,11 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
+22 -6
View File
@@ -3,6 +3,8 @@ import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -11,10 +13,12 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File): Promise<string[]> {
export async function upload(file: File, requestOptions?: RequestOptions): Promise<string[]> {
const formData = new FormData();
formData.append('excel', file);
const response = await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -27,19 +31,31 @@ type PreviewSpreadsheetResponse = {
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
export async function importRundownPreview(
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${excelPath}/preview`,
{
options,
},
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
+4 -2
View File
@@ -2,6 +2,8 @@ import axios from 'axios';
import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = {
url: string;
version: string;
@@ -10,8 +12,8 @@ export type HasUpdate = {
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest);
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
+8 -2
View File
@@ -2,14 +2,19 @@ import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
+3 -2
View File
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data;
}
@@ -0,0 +1,4 @@
export type RequestOptions = {
signal?: AbortSignal;
timeout?: number;
};
@@ -0,0 +1,8 @@
/**
* Keep a short global timeout for regular API requests, but allow
* longer windows for file transfer and heavy import/export operations.
*/
export const axiosConfig = {
shortTimeout: 20 * 1000, // 20 seconds
longTimeout: 3 * 60 * 1000, // 3 minutes
} as const;
+5 -4
View File
@@ -2,6 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
@@ -11,16 +12,16 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to fetch all entries in the currently loaded rundown
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
return res.data;
}
+19 -3
View File
@@ -1,15 +1,16 @@
import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { PortInfo, Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath, { signal: options?.signal });
return res.data;
}
@@ -26,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
/**
* HTTP request to retrieve server port
*/
export async function getServerPort(): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`);
return res.data;
}
/**
* HTTP request to set server port
*/
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
return axios.post(`${settingsPath}/serverport`, { serverPort });
}
+36 -5
View File
@@ -3,6 +3,8 @@ import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ont
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async (
file: File,
sheetId: string,
requestOptions?: RequestOptions,
): Promise<{
verification_url: string;
user_code: string;
@@ -31,6 +34,8 @@ export const requestConnection = async (
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -53,24 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
const response = await axios.post(
`${sheetsPath}/${sheetId}/read`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(
`${sheetsPath}/${sheetId}/worksheets`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
export const uploadRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<void> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/write`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
return res.data;
}
@@ -15,6 +15,11 @@ $thumb-color-hover: $white-60;
height: 100%;
width: 100%;
overscroll-behavior: contain;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
.scrollbar {
@@ -17,7 +17,7 @@ export default function useAppVersion() {
refetch,
} = useQuery({
queryKey: APP_VERSION,
queryFn: getLatestVersion,
queryFn: ({ signal }) => getLatestVersion({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: getAutomationSettings,
queryFn: ({ signal }) => getAutomationSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: getUserTranslation,
queryFn: ({ signal }) => getUserTranslation({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
queryFn: ({ signal }) => getInfo({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -14,12 +14,9 @@ const placeholderProjectList: ProjectFileListResponse = {
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholderProjectList, status, refetch };
}
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -8,11 +8,8 @@ import { fetchReport } from '../api/report';
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: fetchReport,
queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR,
});
@@ -24,7 +24,7 @@ const cachedRundownPlaceholder: Rundown = {
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
select: (data) => {
const unobfuscated = { ...data };
@@ -13,7 +13,7 @@ interface FetchProps {
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
queryFn: ({ signal }) => getUrlPresets({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
enabled: !skip,
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getViewSettings,
queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
+111 -14
View File
@@ -6,15 +6,31 @@ import {
isOntimeEvent,
isOntimeGroup,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
PatchWithId,
Rundown,
SupportedEntry,
TimeField,
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import {
addToRundown,
createDelay,
createEvent,
createGroup,
createMilestone,
dayInMs,
generateId,
getInsertAfterId,
MILLIS_PER_SECOND,
parseUserTime,
resolveInsertParent,
swapEventData,
} from 'ontime-utils';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
@@ -85,9 +101,64 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
const optimisticEntry = createOptimisticEntry(entry);
const parentId = resolveInsertParent(previousData, entry);
const maybeParent = parentId ? previousData.entries[parentId] : null;
const parent = maybeParent && isOntimeGroup(maybeParent) ? maybeParent : null;
const afterId = getInsertAfterId(previousData, parent, entry.after, entry.before);
// create a mutable copy — addToRundown mutates in place using immutable array operations
const newRundown: Rundown = {
...previousData,
entries: { ...previousData.entries },
revision: -1,
};
// copy the parent group so we don't mutate the original
if (parent && parentId) {
newRundown.entries[parentId] = { ...parent, entries: [...parent.entries] };
}
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(RUNDOWN, newRundown);
}
return { previousData };
},
onSuccess: (response) => {
if (!response.data) return;
const serverEntry = response.data;
const currentData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (currentData) {
queryClient.setQueryData<Rundown>(RUNDOWN, {
...currentData,
entries: { ...currentData.entries, [serverEntry.id]: serverEntry },
});
}
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context.previousData);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
@@ -102,7 +173,15 @@ export const useEntryActions = () => {
throw new Error('Rundown not initialised');
}
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
newEntry.after = options.after;
}
if (options?.before) {
newEntry.before = options.before;
}
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
@@ -142,14 +221,6 @@ export const useEntryActions = () => {
}
}
// handle adding options that concern all event type
if (options?.after) {
(newEntry as TransientEventPayload).after = options.after;
}
if (options?.before) {
(newEntry as TransientEventPayload).before = options.before;
}
try {
await addEntryMutation([rundownId, newEntry]);
} catch (error) {
@@ -905,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
return { entries, order, flatOrder };
}
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
if ('parent' in entryData && entryData.parent) {
parent = entryData.parent;
}
switch (entryData.type) {
case SupportedEntry.Event: {
return createEvent({ ...entryData, id, parent }) as OntimeEvent;
}
case SupportedEntry.Delay:
return createDelay({ id, duration: (entryData as Partial<OntimeDelay>).duration ?? 0, parent });
case SupportedEntry.Group:
return createGroup({ ...(entryData as Partial<OntimeGroup>), id });
case SupportedEntry.Milestone:
return createMilestone({ ...(entryData as Partial<OntimeMilestone>), id, parent });
default:
throw new Error('Unknown entry type');
}
}
+4 -4
View File
@@ -202,19 +202,19 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks
// we can force these numbers to 0 for this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks
// we can force these numbers to 0 for this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
}));
@@ -2,7 +2,6 @@ import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = {
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
+10 -3
View File
@@ -1,4 +1,5 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { isOntimeCloud } from '../externals';
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
queries: {
gcTime: 10 * MILLIS_PER_MINUTE,
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
networkMode: 'always',
networkMode: isOntimeCloud ? 'online' : 'always',
refetchOnWindowFocus: false,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
retry: (failureCount, error) => {
if (axios.isCancel(error)) {
return false;
}
return failureCount < 5;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
},
mutations: {
/**
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
retry: 0,
},
},
});
@@ -82,6 +82,57 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
});
describe('cuesheet presets', () => {
const cuesheetPreset: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'full',
write: '-',
},
},
];
const cuesheetPresetWithoutOptions: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-basic',
target: OntimeView.Cuesheet,
search: '',
},
];
const cuesheetPresetWithNavLock: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-locked',
target: OntimeView.Cuesheet,
search: 'n=1',
},
];
it('keeps cuesheet aliases masked when permissions are stored in preset options', () => {
const location = resolvePath('/cuesheet-4685d6');
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6');
});
it('preserves feature params when redirecting masked cuesheet aliases', () => {
const location = resolvePath('/cuesheet-4685d6?n=1&token=123');
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6?n=1&token=123');
});
it('keeps cuesheet aliases masked even when preset options are absent', () => {
const location = resolvePath('/cuesheet-basic');
expect(getRouteFromPreset(location, cuesheetPresetWithoutOptions)).toBe('preset/cuesheet-basic');
});
it('applies navigation lock from preset search params when alias is opened', () => {
const location = resolvePath('/cuesheet-locked');
expect(getRouteFromPreset(location, cuesheetPresetWithNavLock)).toBe('preset/cuesheet-locked?n=1');
});
});
});
describe('generatePathFromPreset()', () => {
@@ -115,6 +166,14 @@ describe('arePathsEquivalent()', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
});
it('distinguishes preset paths with different lock or token params', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?n=1')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1', 'preset/minimal?n=1')).toBeTruthy();
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?token=abc')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1&token=abc')).toBeTruthy();
});
it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
+31 -7
View File
@@ -38,7 +38,13 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000;
const socketConfig = {
reconnectBaseInterval: 1000, // 1 second
reconnectMaxInterval: 30000, // 30 seconds
reconnectMinInterval: 500, // 0.5 seconds
reconnectJitter: 0.25,
offlineAttemptsThreshold: 2, // when we consider the client disconnected
} as const;
export let hasConnected = false;
export let reconnectAttempts = 0;
@@ -49,7 +55,11 @@ export const connectSocket = () => {
const preferredClientName = getClientName();
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
const isReconnect = hasConnected;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
hasConnected = true;
reconnectAttempts = 0;
@@ -59,24 +69,38 @@ export const connectSocket = () => {
path: window.location.pathname + window.location.search,
name: preferredClientName,
});
invalidateAllCaches(); // assume all data to be stale after a reconnect
if (isReconnect) {
invalidateAllCaches();
}
setOnlineStatus(true);
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
const exponentialDelay = Math.min(
socketConfig.reconnectBaseInterval * 2 ** reconnectAttempts,
socketConfig.reconnectMaxInterval,
);
const jitterOffset = exponentialDelay * socketConfig.reconnectJitter * (Math.random() * 2 - 1);
const delay = Math.max(socketConfig.reconnectMinInterval, Math.round(exponentialDelay + jitterOffset));
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
reconnectTimeout = null;
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect');
console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
}, delay);
};
websocket.onerror = (error) => {
+40 -9
View File
@@ -39,11 +39,13 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
// NOTE: verify that this resolves correctly in cloud
const currentPath = `${location.pathname}${location.search}`.substring(1);
const currentURL = getCurrentPath(location);
const token = new URLSearchParams(location.search).get('token');
const isLocked = location.search.includes('n=1');
const locationParams = new URLSearchParams(location.search);
const token = locationParams.get('token');
for (const preset of urlPresets) {
if (!preset.enabled) continue;
const presetParams = new URLSearchParams(preset.search);
const isLocked = locationParams.get('n') === '1' || presetParams.get('n') === '1';
/**
* If the page is a known alias it would be like
* /preset/{alias} <- locked to a preset
@@ -53,7 +55,9 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
* we need to compare the saved preset to the current path to see if we need to redirect
*/
if (preset.alias === currentURL || preset.target === currentURL) {
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
const newPath = shouldMaskPresetPath(preset)
? generateMaskedPathFromPreset(preset.alias, isLocked, token)
: generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
/**
* if the current path is equivalent to the new path, we return null
* this means we will not redirect
@@ -114,6 +118,30 @@ export function generatePathFromPreset(
return `${path.pathname}?${searchParams}`.substring(1);
}
function generateMaskedPathFromPreset(alias: string, locked: boolean, token: string | null): string {
const searchParams = new URLSearchParams();
if (locked) {
searchParams.set('n', '1');
}
if (token) {
searchParams.set('token', token);
}
const path = `preset/${alias}`;
const search = searchParams.toString();
if (!search) {
return path;
}
return `${path}?${search}`;
}
function shouldMaskPresetPath(preset: URLPreset): boolean {
return preset.target === OntimeView.Cuesheet;
}
/**
* Utility checks if two paths are equivalent
* For preset paths, only compares the path (since params are stored in session)
@@ -123,16 +151,19 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin);
// For preset paths, only compare the path
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
return currentUrl.pathname === newUrl.pathname;
}
// For regular paths, compare path and search params (ignoring token)
if (currentUrl.pathname !== newUrl.pathname) {
return false;
}
// For preset paths, only n and token are meaningful — ignore everything else
if (currentUrl.pathname.startsWith('/preset/')) {
return (
currentUrl.searchParams.get('n') === newUrl.searchParams.get('n') &&
currentUrl.searchParams.get('token') === newUrl.searchParams.get('token')
);
}
// For regular paths, compare all search params except n and token
currentUrl.searchParams.delete('token');
currentUrl.searchParams.delete('n');
newUrl.searchParams.delete('token');
@@ -9,7 +9,8 @@
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
flex: 1 1 auto;
min-width: 0;
position: relative;
}
@@ -7,6 +7,8 @@ ul {
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
@@ -54,6 +54,13 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`,
`{{auxtimer${index}.playback}}`,
`{{auxtimer${index}.direction}}`,
];
/**
* Creates a it of possible autocomplete suggestions
* Based on RuntimeState
@@ -68,6 +75,9 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...staticAuxProperties(1),
...staticAuxProperties(2),
...staticAuxProperties(3),
];
}
@@ -1,5 +1,7 @@
import { ImportCustom, ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage';
export type NamedImportMap = typeof namedImportMap;
// Record of label and import name
@@ -63,12 +65,14 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
};
}
const importMapKey = makeStageKey('import-map');
export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('import-options', JSON.stringify(options));
localStorage.setItem(importMapKey, JSON.stringify(options));
}
function getPersistImportMap(): unknown {
const options = localStorage.getItem('import-options');
const options = localStorage.getItem(importMapKey);
if (!options) {
throw new Error('no import options found');
}
@@ -7,12 +7,9 @@ import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput';
@@ -32,7 +29,6 @@ export default function GeneralSettings() {
} = useForm<Settings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
@@ -102,33 +98,6 @@ export default function GeneralSettings() {
<Info>Changes to the time format and views language do not affect the editor view</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description={
isOntimeCloud
? 'Server port disabled for Ontime Cloud'
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
}
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
disabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Editor pin code'
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { PortInfo } from 'ontime-types';
import { getServerPort, postServerPort } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Tag from '../../../../common/components/tag/Tag';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
interface ServerPortForm {
serverPort: number;
}
export default function ServerPortSettings() {
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<ServerPortForm>({
mode: 'onChange',
defaultValues: { serverPort: 4001 },
});
const [pendingRestart, setPendingRestart] = useState<boolean>(false);
const setPort = useCallback((info: PortInfo) => {
reset({ serverPort: info.port });
setPendingRestart(info.pendingRestart);
}, []);
useEffect(() => {
getServerPort()
.then(setPort)
.catch(() => setError('root', { message: 'Failed to load server port' }));
}, [reset, setError, setPort]);
const onSubmit = async (formData: ServerPortForm) => {
if (formData.serverPort < 1024 || formData.serverPort > 65535) {
setError('serverPort', { message: 'Port must be within range 1024 - 65535' });
return;
}
try {
await postServerPort(formData.serverPort);
setPort(await getServerPort());
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const onReset = async () => {
try {
setPort(await getServerPort());
} catch (error) {
setError('root', { message: 'Failed to load server port' });
}
};
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='server-port-settings'
>
<Panel.Card>
<Panel.SubHeader>
Server port
<Panel.InlineElements>
{pendingRestart && <Tag>A port change is pending and will happen on the next restart</Tag>}
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button
type='submit'
form='server-port-settings'
name='server-port-settings-submit'
loading={isSubmitting}
disabled={!isDirty || !isValid || isSubmitting}
variant='primary'
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,14 +1,17 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralSettings from './GeneralSettings';
import ProjectData from './ProjectData';
import ServerPortSettings from './ServerPortSettings';
import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
return (
@@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={viewRef}>
<ViewSettings />
</div>
{!isDocker && (
<div ref={portRef}>
<ServerPortSettings />
</div>
)}
</>
);
}
@@ -31,7 +31,6 @@ export default function ViewSettings() {
formState: { isSubmitting, isDirty, errors },
} = useForm<ViewSettingsType>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
@@ -1,4 +1,4 @@
import { lazy, useEffect, useRef, useState } from 'react';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
import Button from '../../../../../common/components/buttons/Button';
@@ -81,7 +81,9 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
showCloseButton
showBackdrop
bodyElements={
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
<Suspense fallback={null}>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</Suspense>
}
footerElements={
<div className={style.column}>
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import useAppVersion from '../../common/hooks-query/useAppVersion';
import { isDocker } from '../../externals';
export type SettingsOption = {
id: string;
@@ -18,6 +19,7 @@ const staticOptions = [
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' },
{ id: 'settings__port', label: 'Server Port' },
],
},
{
@@ -102,6 +104,14 @@ export function useAppSettingsMenu() {
() =>
staticOptions.map((option) => ({
...option,
// if we are in docker don't show the port option
secondary:
'secondary' in option
? isDocker && option.id === 'settings'
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
: [...option.secondary]
: undefined,
// if there is an update then highlight the about setting
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
})),
[data],
@@ -42,7 +42,11 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
<div className={style.indicatorNegative} data-active={isOvertime} />
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
</div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
<TimerDisplay
className={style.timerDisplay}
time={isWaiting ? timer.secondaryTimer : timer.current}
phase={timer.phase}
/>
<div className={style.status}>
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
@@ -1,4 +1,5 @@
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
@@ -20,7 +21,7 @@ interface OperatorEventProps {
timeStart: number;
duration: number;
delay: number;
dayOffset: number;
dayOffset: Day;
isLinkedToLoaded: boolean;
isSelected: boolean;
isPast: boolean;
@@ -138,7 +139,7 @@ interface OperatorEventScheduleProps {
isPast: boolean;
isSelected: boolean;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
}
@@ -173,7 +174,7 @@ function OperatorEventSchedule({
interface TimeUntilProps {
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
}
+2 -3
View File
@@ -1,4 +1,4 @@
import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type HTMLProps, forwardRef, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { closestCenter, DndContext } from '@dnd-kit/core';
@@ -26,14 +26,13 @@ import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import RundownEntry from './RundownEntry';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEditorFollowMode } from './useEditorFollowMode';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: RundownType['entries'];
id: RundownType['id'];
@@ -12,7 +12,7 @@ import {
import { TbFlagFilled } from 'react-icons/tb';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { Day, EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
@@ -55,7 +55,7 @@ interface RundownEventProps {
isRolling: boolean;
gap: number;
isNextDay: boolean;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
hasTriggers: boolean;
@@ -10,7 +10,7 @@ import {
IoTime,
} from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { Day, EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils';
@@ -44,7 +44,7 @@ interface RundownEventInnerProps {
loaded: boolean;
playback?: Playback;
isRolling: boolean;
dayOffset: number;
dayOffset: Day;
isPast: boolean;
totalGap: number;
isLinkedToLoaded: boolean;
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
import { Day } from 'ontime-types';
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
@@ -14,7 +15,7 @@ interface RundownEventChipProps {
id: string;
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
isPast: boolean;
isLoaded: boolean;
className: string;
@@ -68,7 +69,7 @@ export default function RundownEventChip({
interface EventUntilProps {
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
}
@@ -1,49 +1,19 @@
import { memo, use, useEffect, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { memo, use, useMemo } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from './useTablePermissions';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const preset = use(PresetContext);
// set permissions based on preset
useEffect(() => {
if (preset) {
const fullWrite = preset.options?.write === 'full';
setPermissions({
canChangeMode: preset.options?.write !== '-',
canCreateEntries: fullWrite,
canEditEntries: fullWrite,
canFlag: fullWrite || Boolean(preset.options?.write.includes('flag')),
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
});
} else {
setPermissions({
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: sessionScope === 'rw',
});
}
}, [preset, setPermissions]);
const [cuesheetMode] = useSessionStorage({
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
@@ -54,7 +24,16 @@ function CuesheetTableWrapper() {
return (
<CuesheetDnd columns={columns}>
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
/>
)}
</CuesheetDnd>
);
}
@@ -0,0 +1,94 @@
import { OntimeView, URLPreset } from 'ontime-types';
import { AppMode } from '../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy, getCuesheetPermissionsPolicy } from '../cuesheet.policies';
describe('getCuesheetPermissionsPolicy()', () => {
test('returns full permissions when there is no preset', () => {
expect(getCuesheetPermissionsPolicy(undefined, true)).toEqual({
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: true,
});
});
test('returns run-only permissions when write is disabled', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-read-only',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'full',
write: '-',
},
};
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
canChangeMode: false,
canCreateEntries: false,
canEditEntries: false,
canFlag: false,
canShare: false,
});
});
test('allows flag changes when write includes flag only', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-flag',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'full',
write: 'flag',
},
};
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
canChangeMode: true,
canCreateEntries: false,
canEditEntries: false,
canFlag: true,
canShare: false,
});
});
test('defaults to full read and write when cuesheet options are absent', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-default',
target: OntimeView.Cuesheet,
search: '',
};
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
expect(policy.canRead('title')).toBe(true);
expect(policy.canWrite('title')).toBe(true);
});
test('column access honors granular permissions and mode', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-granular',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'cue,title',
write: 'title',
},
};
const editPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
const runPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Run);
expect(editPolicy.canRead('cue')).toBe(true);
expect(editPolicy.canRead('duration')).toBe(false);
expect(editPolicy.canWrite('title')).toBe(true);
expect(editPolicy.canWrite('cue')).toBe(false);
expect(runPolicy.canWrite('title')).toBe(false);
});
});
@@ -34,13 +34,24 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
import style from './CuesheetTable.module.scss';
interface CuesheetTableProps {
type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
tableRoot?: 'editor' | 'cuesheet';
}
};
export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) {
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
const { data, status } = useFlatRundownWithMetadata();
const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -206,17 +217,25 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
return <EmptyPage text='Loading...' />;
}
// control components need different implementations for handling permissions
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
return (
<>
<TableRootSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
{tableRoot === 'editor' ? (
<EditorTableSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
) : (
<CuesheetTableSettings
columns={allLeafColumns}
cuesheetMode={cuesheetMode}
setCuesheetMode={setCuesheetMode}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
)}
<TableVirtuoso
ref={virtuosoRef}
data={data}
@@ -7,6 +7,7 @@ import DelayIndicator from '../../../../common/components/delay-indicator/DelayI
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
@@ -257,15 +258,7 @@ export function makeCuesheetColumns(
preset: URLPreset | undefined,
): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
const fullRead = preset ? preset.options?.read === 'full' : true;
const fullWrite = preset ? preset.options?.write === 'full' : true;
const canWriteKeys = preset?.options?.write ? new Set(preset.options.write.split(',')) : new Set<string>();
const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set<string>();
// helpers to check read/write for a given key
const canRead = (key: string) => fullRead || canReadKeys.has(key);
const canWrite = (key: string) => modeAllowsWrite && (fullWrite || canWriteKeys.has(key));
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) {
columnsDef.push({
@@ -1,20 +1,18 @@
import { ReactNode, use } from 'react';
import { ReactNode } from 'react';
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar';
import { useSessionStorage } from '@mantine/hooks';
import type { Column } from '@tanstack/react-table';
import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import { PresetContext } from '../../../../common/context/PresetContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx } from '../../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
import { AppMode } from '../../../../ontimeConfig';
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetPermissions } from '../../useTablePermissions';
@@ -24,6 +22,8 @@ import style from './CuesheetTableSettings.module.scss';
interface CuesheetTableSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
@@ -42,19 +42,16 @@ export interface ColumnSettingsProps {
export default function CuesheetTableSettings({
columns,
cuesheetMode,
setCuesheetMode,
handleResetResizing,
handleResetReordering,
handleClearToggles,
}: CuesheetTableSettingsProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare);
const preset = use(PresetContext);
const options = usePersistedCuesheetOptions();
const [cuesheetMode, setCuesheetMode] = useSessionStorage({
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const toggleCuesheetMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0);
@@ -71,14 +68,20 @@ export default function CuesheetTableSettings({
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
<ToggleGroup value={[cuesheetMode]} onValueChange={toggleCuesheetMode} className={cx([style.group, style.apart])}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
{canChangeMode && (
<ToggleGroup
value={[cuesheetMode]}
onValueChange={toggleCuesheetMode}
className={cx([style.group, style.apart])}
>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
)}
{canShare && (
<>
@@ -0,0 +1,65 @@
import { URLPreset } from 'ontime-types';
import { AppMode } from '../../ontimeConfig';
import type { CuesheetPermissions } from './useTablePermissions';
function getPermissionKeys(permission: string | undefined): Set<string> {
return permission ? new Set(permission.split(',')) : new Set<string>();
}
function getCuesheetPermissions(readPermission: string | undefined, writePermission: string | undefined) {
const readKeys = getPermissionKeys(readPermission);
const writeKeys = getPermissionKeys(writePermission);
const fullRead = readPermission == null || readPermission === 'full';
const fullWrite = writePermission == null || writePermission === 'full';
return {
writePermission,
readKeys,
writeKeys,
fullRead,
fullWrite,
};
}
export function getCuesheetPermissionsPolicy(
preset: URLPreset | undefined,
canShareInSession: boolean,
): CuesheetPermissions {
if (!preset) {
return {
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: canShareInSession,
};
}
const { writePermission, writeKeys, fullWrite } = getCuesheetPermissions(
preset?.options?.read,
preset?.options?.write,
);
return {
canChangeMode: writePermission !== '-',
canCreateEntries: fullWrite,
canEditEntries: fullWrite,
canFlag: fullWrite || writeKeys.has('flag'),
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
};
}
export function getCuesheetColumnAccessPolicy(preset: URLPreset | undefined, cuesheetMode: AppMode) {
const { readKeys, writeKeys, fullRead, fullWrite } = getCuesheetPermissions(
preset?.options?.read,
preset?.options?.write,
);
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
return {
canRead: (key: string) => fullRead || readKeys.has(key),
canWrite: (key: string) => modeAllowsWrite && (fullWrite || writeKeys.has(key)),
};
}
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { URLPreset } from 'ontime-types';
import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
import { useCuesheetPermissions } from './useTablePermissions';
/**
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
*/
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
} {
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const canShareInSession = sessionScope === 'rw';
const permissions = useMemo(
() => getCuesheetPermissionsPolicy(preset, canShareInSession),
[preset, canShareInSession],
);
const [storedCuesheetMode, setStoredCuesheetMode] = useSessionStorage({
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
const setCuesheetMode = useCallback(
(mode: AppMode) => {
if (!permissions.canChangeMode) {
return;
}
setStoredCuesheetMode(mode);
},
[permissions.canChangeMode, setStoredCuesheetMode],
);
// Keep the shared permissions store aligned with the active preset policy.
useEffect(() => {
setPermissions(permissions);
}, [permissions, setPermissions]);
// Force Run mode whenever the policy forbids mode switching.
useEffect(() => {
if (!permissions.canChangeMode) {
setStoredCuesheetMode((mode) => (mode === AppMode.Run ? mode : AppMode.Run));
}
}, [permissions.canChangeMode, setStoredCuesheetMode]);
return { cuesheetMode, setCuesheetMode };
}
@@ -6,9 +6,11 @@ interface CuesheetPermissionsStore {
canEditEntries: boolean;
canFlag: boolean;
canShare: boolean;
setPermissions: (permissions: Omit<CuesheetPermissionsStore, 'setPermissions'>) => void;
setPermissions: (permissions: CuesheetPermissions) => void;
}
export type CuesheetPermissions = Omit<CuesheetPermissionsStore, 'setPermissions'>;
export const useCuesheetPermissions = create<CuesheetPermissionsStore>((set) => ({
canChangeMode: false,
canCreateEntries: false,
@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react';
import { OntimeEvent } from 'ontime-types';
import { Day, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
@@ -78,7 +78,7 @@ function TitleListItem({
interface TitleListTimeUntilChipProps {
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
isLoaded: boolean;
@@ -1,4 +1,5 @@
import { RefObject } from 'react';
import { Day } from 'ontime-types';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
@@ -20,7 +21,7 @@ interface TimelineEntryProps {
left: number;
status: ProgressStatus;
start: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
title: string;
@@ -119,7 +120,7 @@ export function TimelineEntry({
interface TimelineEntryStatusProps {
delay: number;
start: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
status: ProgressStatus;
+2 -2
View File
@@ -181,10 +181,10 @@ export function getCardData(
: getPropertyValue(eventNow, secondarySource, entries);
return {
showNow: mainSource !== 'none' || Boolean(nowSecondary),
showNow: mainSource !== 'none' && (Boolean(nowMain) || Boolean(nowSecondary)),
nowMain,
nowSecondary,
showNext: mainSource !== 'none' || Boolean(nextSecondary),
showNext: mainSource !== 'none' && (Boolean(nextMain) || Boolean(nextSecondary)),
nextMain,
nextSecondary,
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.4.0",
"version": "4.5.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.4.0",
"version": "4.5.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.4.0",
"version": "4.5.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -1,7 +1,6 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
import { testConditions, triggerAutomations } from '../automation.service.js';
@@ -10,6 +9,7 @@ import * as httpClient from '../clients/http.client.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -40,9 +40,20 @@ describe('triggerAction()', () => {
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
let httpSpy = vi.spyOn(httpClient, 'emitHTTP');
beforeAll(() => {
vi.mock('../../../stores/EventStore.js', () => {
// Create a small mock store
return {
eventStore: {
poll: vi.fn().mockImplementation(() => makeRuntimeStoreData()),
},
};
});
})
beforeEach(async () => {
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => { });
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => { });
await deleteAllTriggers();
const oscAutomation = await addAutomation({
@@ -70,26 +81,25 @@ describe('triggerAction()', () => {
});
it('should trigger automations for a given action', () => {
const state = makeRuntimeStateData();
triggerAutomations(TimerLifeCycle.onLoad, state);
triggerAutomations(TimerLifeCycle.onLoad);
expect(oscSpy).toHaveBeenCalledTimes(1);
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onStart, state);
triggerAutomations(TimerLifeCycle.onStart);
expect(oscClient.emitOSC).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onFinish, state);
triggerAutomations(TimerLifeCycle.onFinish);
expect(oscSpy).not.toBeCalled();
expect(httpSpy).toHaveBeenCalledTimes(1);
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onStop, state);
triggerAutomations(TimerLifeCycle.onStop);
expect(oscSpy).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
});
@@ -540,7 +550,7 @@ describe('testConditions()', () => {
describe('for all filter rule', () => {
it('should return true when all filters are true', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
@@ -560,7 +570,7 @@ describe('testConditions()', () => {
});
it('should return false if any filters are false', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
@@ -582,7 +592,7 @@ describe('testConditions()', () => {
describe('for any filter rule', () => {
it('should return true when all filters are true', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
@@ -602,7 +612,7 @@ describe('testConditions()', () => {
});
it('should return true if any filters are true', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'not-test',
@@ -622,7 +632,7 @@ describe('testConditions()', () => {
});
it('should return false if all filters are false', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent,
});
@@ -3,6 +3,7 @@ import {
isOntimeAction,
isOSCOutput,
LogOrigin,
RuntimeStore,
TimerLifeCycle,
type AutomationFilter,
type AutomationOutput,
@@ -10,29 +11,30 @@ import {
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
import { toOntimeAction } from './clients/ontime.client.js';
import { logger } from '../../classes/Logger.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { eventStore } from '../../stores/EventStore.js';
/**
* Exposes a method for triggering actions based on a TimerLifeCycle event
*/
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
export function triggerAutomations(cycle: TimerLifeCycle) {
if (!getAutomationsEnabled()) {
return;
}
const store = eventStore.poll();
let triggers = getAutomationTriggers();
// get triggers from event
if (state.eventNow?.triggers) {
triggers = triggers.concat(state.eventNow.triggers);
if (store.eventNow?.triggers) {
triggers = triggers.concat(store.eventNow.triggers);
}
// note: there are no onStop triggers in event
@@ -51,9 +53,9 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
if (!automation || automation.outputs.length === 0) {
return;
}
const shouldSend = testConditions(automation.filters, automation.filterRule, state);
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
if (shouldSend) {
send(automation.outputs, state);
send(automation.outputs, store);
}
});
}
@@ -62,7 +64,8 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
* Exposes a method for bypassing the condition check and testing the sending of an output
*/
export function testOutput(payload: AutomationOutput) {
send([payload]);
const store = eventStore.poll();
send([payload], store);
}
/**
@@ -71,7 +74,7 @@ export function testOutput(payload: AutomationOutput) {
export function testConditions(
filters: AutomationFilter[],
filterRule: FilterRule,
state: Partial<RuntimeState>,
store: Partial<RuntimeStore>,
): boolean {
if (filters.length === 0) {
return true;
@@ -86,7 +89,7 @@ export function testConditions(
function evaluateCondition(filter: AutomationFilter): boolean {
const { field, operator, value } = filter;
const lowerCasedValue = value.toLowerCase();
const fieldValue = getPropertyFromPath(field, state);
const fieldValue = getPropertyFromPath(field, store);
// if value is empty string, the user could be meaning to check if the value does not exist
// we use loose equality to be able to check for converted values (eg '10' == 10)
@@ -115,13 +118,12 @@ export function testConditions(
* Handles preparing and sending of the data
* Returns a boolean indicating whether a message was sent
*/
function send(output: AutomationOutput[], state?: RuntimeState) {
const stateSnapshot = state ?? getState();
function send(output: AutomationOutput[], store: RuntimeStore) {
output.forEach((payload) => {
if (isOSCOutput(payload) && !isOntimeCloud) {
emitOSC(payload, stateSnapshot);
emitOSC(payload, store);
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, stateSnapshot);
emitHTTP(payload, store);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
} else {
@@ -1,20 +1,20 @@
import { HTTPOutput, LogOrigin } from 'ontime-types';
import { DeepReadonly } from 'ts-essentials';
import { HTTPOutput, LogOrigin, RuntimeStore } from 'ontime-types';
import { logger } from '../../../classes/Logger.js';
import type { RuntimeState } from '../../../stores/runtimeState.js';
import { parseTemplateNested } from '../automation.utils.js';
/**
* Expose possibility to send a message using HTTP protocol
*/
export function emitHTTP(output: HTTPOutput, state: RuntimeState) {
const url = preparePayload(output, state);
export function emitHTTP(output: HTTPOutput, store: DeepReadonly<RuntimeStore>) {
const url = preparePayload(output, store);
emit(url);
}
/** Parses the state and prepares payload to be emitted */
function preparePayload(output: HTTPOutput, state: RuntimeState): string {
function preparePayload(output: HTTPOutput, state: DeepReadonly<RuntimeStore>): string {
const parsedUrl = parseTemplateNested(output.url, state);
return parsedUrl;
}
@@ -1,29 +1,29 @@
import { LogOrigin, OSCOutput } from 'ontime-types';
import { LogOrigin, OSCOutput, RuntimeStore } from 'ontime-types';
import { type OscPacketInput, toBuffer as oscPacketToBuffer } from 'osc-min';
import * as dgram from 'node:dgram';
import { logger } from '../../../classes/Logger.js';
import { type RuntimeState } from '../../../stores/runtimeState.js';
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { DeepReadonly } from 'ts-essentials';
const udpClient = dgram.createSocket('udp4');
/**
* Expose possibility to send a message using OSC protocol
*/
export function emitOSC(output: OSCOutput, state: RuntimeState) {
const message = preparePayload(output, state);
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
const message = preparePayload(output, store);
emit(output.targetIP, output.targetPort, message);
}
/** Parses the state and prepares payload to be emitted */
function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput {
function preparePayload(output: OSCOutput, store: DeepReadonly<RuntimeStore>): OscPacketInput {
// check for templates in the address
const parsedAddress = parseTemplateNested(output.address, state);
const parsedAddress = parseTemplateNested(output.address, store);
// check for templates in the arguments
const parsedArguments = output.args ? parseTemplateNested(output.args, state) : undefined;
const parsedArguments = output.args ? parseTemplateNested(output.args, store) : undefined;
// check we have the correct type
const oscArguments = stringToOSCArgs(parsedArguments);
return { address: parsedAddress, args: oscArguments };
+29 -3
View File
@@ -10,6 +10,9 @@ import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import * as v3 from './migration/db.migration.v3.js';
import * as v4 from './migration/db.migration.v4.js';
import { portManager } from '../../classes/port-manager/PortManager.js';
import { getErrorMessage } from 'ontime-utils';
type ParsingError = {
context: string;
@@ -21,21 +24,45 @@ type ParsingError = {
* @param {object} jsonData - project file to be parsed
* @returns {object} parsed object
*/
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
export function parseDatabaseModel(
jsonData: Partial<DatabaseModel>,
initialLoad = false,
): {
data: DatabaseModel;
errors: ParsingError[];
migrated: boolean;
} {
let migrated = false;
let migratedData = jsonData;
const errors: ParsingError[] = [];
if (v3.shouldUseThisMigration(jsonData)) {
try {
migrated = true;
logger.warning(LogOrigin.Server, 'The imported project is from v3, trying to migrate');
migratedData = v3.migrateAllData(jsonData);
} catch (_error) {
} catch (error) {
logger.error(LogOrigin.Server, 'Failed to migrate the data');
errors.push({ context: 'v3 migration', message: getErrorMessage(error) });
migratedData = jsonData;
};
}
if (v4.shouldMigrateServerPort(migratedData)) {
try {
migrated = true;
logger.warning(LogOrigin.Server, 'Migrating serverPort from settings to AppState');
const { db, serverPort } = v4.migrateServerPort(migratedData);
if (initialLoad && serverPort) portManager.migratePortFromProjectFile(serverPort);
migratedData = db;
} catch (error) {
logger.error(LogOrigin.Server, 'Failed to migrate serverPort');
errors.push({ context: 'v4 migration', message: getErrorMessage(error) });
migratedData = jsonData;
}
}
@@ -43,7 +70,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
// this may throw
const settings = parseSettings(migratedData);
const errors: ParsingError[] = [];
const makeEmitError = (context: string) => (message: string) => {
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
errors.push({ context, message });
@@ -16,12 +16,17 @@ import {
Trigger,
URLPreset,
ViewSettings,
Day,
} from 'ontime-types';
import { customFieldLabelToKey, checkRegex, isKnownTimerType, validateEndAction } from 'ontime-utils';
import {
customFieldLabelToKey,
checkRegex,
isKnownTimerType,
validateEndAction,
eventDef as eventModel,
} from 'ontime-utils';
import { is } from '../../../utils/is.js';
import { event as eventModel } from '../../../models/eventsDefinition.js';
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
import { getPartialProject } from '../../../models/dataModel.js';
// the methodology of the migrations is to just change the necessary keys to match with v4
@@ -64,12 +69,12 @@ type old_Settings = {
* migrates a settings from v3 to v4
* - update the version number
*/
export function migrateSettings(jsonData: object): Settings | undefined {
export function migrateSettings(jsonData: object): (Settings & { serverPort: number }) | undefined {
if (is.objectWithKeys(jsonData, ['settings']) && is.object(jsonData.settings)) {
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
jsonData.settings,
) as old_Settings;
return { version: ONTIME_VERSION, serverPort, editorKey, operatorKey, timeFormat, language };
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
}
}
@@ -389,7 +394,7 @@ export function migrateRundown(
// !==== RUNTIME METADATA ====! //
revision: -1,
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
});
} else if (entry.type === 'block') {
@@ -0,0 +1,35 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { is } from '../../../utils/is.js';
export function shouldMigrateServerPort(jsonData: object): boolean {
return (
is.objectWithKeys(jsonData, ['settings']) &&
is.object(jsonData.settings) &&
is.objectWithKeys(jsonData.settings, ['version', 'serverPort']) &&
typeof jsonData.settings.version === 'string' &&
jsonData.settings.version.split('.')[0] === '4' &&
Number(jsonData.settings.version.split('.')[1]) <= 4
);
}
export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
db: Partial<DatabaseModel>;
serverPort?: number;
} {
const db = structuredClone(jsonData);
const settings = db.settings as Partial<Settings & { serverPort: number }>;
const editorKey = settings?.editorKey;
const operatorKey = settings?.operatorKey;
const timeFormat = settings?.timeFormat;
const language = settings?.language;
const version = '4.5.0';
db.settings = {
version,
editorKey,
operatorKey,
timeFormat,
language,
app: 'ontime',
} as Settings;
return { db, serverPort: settings?.serverPort };
}
@@ -1,6 +1,7 @@
import {
AutomationSettings,
CustomFields,
DatabaseModel,
EndAction,
OntimeView,
ProjectData,
@@ -14,8 +15,7 @@ import {
ViewSettings,
} from 'ontime-types';
import * as v3 from './db.migration.v3.js';
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
import * as v4 from './db.migration.v4.js';
describe('v3 to v4', () => {
const oldDb = {
@@ -175,8 +175,8 @@ describe('v3 to v4', () => {
};
test('migrate settings', () => {
const expectSettings: Settings = {
version: ONTIME_VERSION,
const expectSettings: Settings & { serverPort: number } = {
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
@@ -468,7 +468,7 @@ describe('v3 to v4', () => {
const expectedAutomation: AutomationSettings = {
enabledAutomations: true,
enabledOscIn: true,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [
{
@@ -493,3 +493,101 @@ describe('v3 to v4', () => {
});
});
});
describe('v4 remove server port', () => {
const demoDb = {
rundowns: {},
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
url: 'www.github.com/cpvalente/ontime',
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
logo: null,
custom: [],
},
settings: {
app: 'ontime',
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
dangerColor: '#ff7300',
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#ffa528',
},
customFields: {
song: {
label: 'Song',
type: 'text',
colour: '#339E4E',
},
artist: {
label: 'Artist',
type: 'text',
colour: '#3E75E8',
},
},
urlPresets: [
{
enabled: true,
alias: 'clock',
target: 'timer',
search:
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
{
enabled: true,
alias: 'minimal',
target: 'timer',
search:
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
],
automation: {
enabledAutomations: false,
enabledOscIn: true,
oscPortIn: 8888,
triggers: [],
automations: {},
},
};
it('should migrate if server port exists', () => {
expect(v4.shouldMigrateServerPort(demoDb)).toBeTruthy();
});
test('should not migrate if the version is newer', () => {
expect(
v4.shouldMigrateServerPort({ settings: { version: '5.0.0', serverPort: 4001 } } as unknown as DatabaseModel),
).toBeFalsy();
});
test('should not migrate if there is no server port', () => {
expect(v4.shouldMigrateServerPort({ settings: { version: '4.0.0' } } as DatabaseModel)).toBeFalsy();
});
it('remove server port from project', () => {
const { db: result, serverPort } = v4.migrateServerPort(demoDb as DatabaseModel);
expect(result).not.toHaveProperty('settings.serverPort');
expect(serverPort).toBe(4001);
expect(result.automation).toMatchObject(demoDb.automation);
expect(result.customFields).toMatchObject(demoDb.customFields);
expect(result.project).toMatchObject(demoDb.project);
expect(result.rundowns).toMatchObject(demoDb.rundowns);
expect(result.settings).toMatchObject({
app: 'ontime',
version: '4.5.0',
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
expect(result.urlPresets).toMatchObject(demoDb.urlPresets);
expect(result.viewSettings).toMatchObject(demoDb.viewSettings);
});
});
@@ -18,12 +18,12 @@ import {
isKnownTimerType,
validateTimerType,
validateEndAction,
makeString,
} from 'ontime-utils';
import { Prettify } from 'ts-essentials';
import { is } from '../../utils/is.js';
import { makeString } from '../../utils/parserUtils.js';
import { parseExcelDate } from '../../utils/time.js';
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
@@ -33,10 +33,11 @@ export function getCustomFieldData(
// we lower case the excel key to make it easier to match
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
const maybeExistingType = existingCustomFields[keyInCustomFields]?.type ?? 'text';
// 1. add the custom field to the merged custom fields
mergedCustomFields[keyInCustomFields] = {
type: 'text', // we currently only support text custom fields
type: maybeExistingType,
colour: maybeExistingColour,
label: ontimeLabel,
};
@@ -1,5 +1,5 @@
import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { createEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import { assertType } from 'vitest';
@@ -7,11 +7,9 @@ import { demoDb } from '../../../models/demoProject.js';
import {
calculateDayOffset,
createEvent,
deleteById,
doesInvalidateMetadata,
duplicateRundown,
getInsertAfterId,
hasChanges,
makeDeepClone,
} from '../rundown.utils.js';
@@ -226,45 +224,6 @@ describe('calculateDayOffset()', () => {
});
});
describe('getInsertAfterId()', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({ id: '1', parent: null }),
'2': makeOntimeEvent({ id: '2', parent: null }),
group: makeOntimeGroup({ id: 'group', entries: ['31', '32'] }),
'31': makeOntimeEvent({ id: '31', parent: 'group' }),
'32': makeOntimeEvent({ id: '32', parent: 'group' }),
'4': makeOntimeEvent({ id: '4', parent: null }),
},
order: ['1', '2', 'group', '4'],
flatOrder: ['1', '2', 'group', '31', '32', '4'],
});
it('returns afterId if provided', () => {
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
});
it('returns null if neither afterId nor beforeId is provided', () => {
expect(getInsertAfterId(rundown, null)).toBeNull();
});
it('returns null if beforeId is not found', () => {
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
});
it('returns the previous id of an entry in the rundown', () => {
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
});
it('returns the previous id of an event in a group', () => {
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
});
});
describe('duplicateRundown', () => {
it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default'];
+12 -66
View File
@@ -26,7 +26,7 @@ import {
Rundown,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { addToRundown, customFieldLabelToKey, getInsertAfterId, insertAtIndex, createGroup } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
@@ -35,10 +35,8 @@ import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneSimpleRundownEntry,
createGroup,
deleteById,
doesInvalidateMetadata,
getInsertAfterId,
getUniqueId,
makeDeepClone,
} from './rundown.utils.js';
@@ -110,11 +108,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
function commit(shouldProcess: boolean = true) {
// if the rundown is mutable we persist the changes
if (options.mutableRundown) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
// update fields which are agnostic of whether the rundown is processed
cachedRundown.revision = cachedRundown.revision + 1;
cachedRundown.title = rundown.title;
@@ -135,16 +128,17 @@ export function createTransaction(options: TransactionOptions): Transaction {
cachedRundown.flatOrder = metadata.flatEntryOrder;
rundownMetadata = metadata;
}
// persist after all mutations are applied
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
}
// if the customFields are mutable we persist the changes
if (options.mutableCustomFields) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setCustomFields(projectCustomFields);
});
projectCustomFields = customFields;
// persist after reassignment
getDataProvider().setCustomFields(projectCustomFields);
}
return {
@@ -163,50 +157,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
};
}
/**
* Add entry to rundown, handles the following cases:
* - 1a. add entry in group, after a given entry
* - 1b. add entry in group, at the beginning
* - 2a. add entry to the rundown, after a given entry
* - 2b. add entry to the rundown, at the beginning
*/
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry {
if (parent) {
// 1. inserting an entry inside a group
if ('parent' in entry) {
entry.parent = parent.id;
}
if (afterId) {
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
parent.entries = insertAtIndex(0, entry.id, parent.entries);
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
} else {
// 2. inserting an entry at top level
if (afterId) {
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
}
// either way, we insert the entry into the rundown
rundown.entries[entry.id] = entry;
return entry;
}
/**
* Applies a patch of changes to an existing entry
* @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache
@@ -516,7 +466,7 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
after = entry.id;
}
return add(rundown, clonedEntry, after, parent);
return addToRundown(rundown, clonedEntry, after, parent);
}
}
@@ -583,7 +533,7 @@ function ungroup(rundown: Rundown, group: OntimeGroup) {
}
export const rundownMutation = {
add,
add: addToRundown,
edit,
remove,
removeAll,
@@ -598,10 +548,8 @@ export const rundownMutation = {
/**
* Exposes a way to update a rundown which is not active
*/
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
setImmediate(async () => {
await getDataProvider().setRundown(rundownId, rundown);
});
export async function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
await getDataProvider().setRundown(rundownId, rundown);
}
/**
@@ -698,9 +646,7 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
rundownMetadata = metadata;
// defer writing to the database
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
}
@@ -16,14 +16,24 @@ import {
isOntimeMilestone,
OntimeMilestone,
OntimeGroup,
Day,
} from 'ontime-types';
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import {
isObjectEmpty,
generateId,
getLinkedTimes,
getTimeFrom,
isNewLatest,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { delay as delayDef } from '../../models/eventsDefinition.js';
import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, cleanupCustomFields, createGroup, createEvent, createMilestone } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
@@ -107,7 +117,7 @@ export function parseRundown(
cleanupCustomFields(newEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
newEvent = createDelay({ duration: event.duration, id });
} else if (isOntimeMilestone(event)) {
newEvent = createMilestone({ ...event, id });
cleanupCustomFields(newEvent.custom, parsedCustomFields);
@@ -134,7 +144,7 @@ export function parseRundown(
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(nestedEvent)) {
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id: nestedEventId };
newNestedEvent = createDelay({ duration: nestedEvent.duration, id: nestedEventId });
newNestedEvent.parent = event.id;
} else if (isOntimeMilestone(nestedEvent)) {
newNestedEvent = createMilestone({ ...nestedEvent, id: nestedEventId });
@@ -272,7 +282,7 @@ function processEntry<T extends OntimeEntry>(
sanitiseCustomFields(customFields, currentEntry);
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
currentEntry.dayOffset = processedData.totalDays;
currentEntry.dayOffset = processedData.totalDays as Day;
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.parent = childOfGroup;
@@ -16,7 +16,7 @@ import {
ProjectRundowns,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey } from 'ontime-utils';
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
@@ -33,7 +33,7 @@ import {
updateBackgroundRundown,
} from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
import { generateEvent, hasChanges } from './rundown.utils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
@@ -47,28 +47,15 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
throw new Error(`Event with ID ${eventData.id} already exists`);
}
// the parent can be provided or inferred from position
// resolve the parent, either from the payload or inferred from sibling position
const parentId = resolveInsertParent(rundown, eventData);
let parent: OntimeGroup | null = null;
if ('parent' in eventData && eventData.parent != null) {
// if the user provides a parent (inside a group), we make sure it exists and it is a group
const maybeParent = rundown.entries[eventData.parent];
if (parentId) {
const maybeParent = rundown.entries[parentId];
if (!maybeParent || !isOntimeGroup(maybeParent)) {
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
throw new Error(`Invalid parent event with ID ${parentId}`);
}
parent = maybeParent;
} else {
// otherwise, we may infer the parent from relative positioning (after/before)
const referenceId = eventData?.after ?? eventData?.before;
if (referenceId) {
const maybeSibling = rundown.entries[referenceId];
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
const maybeParent = rundown.entries[maybeSibling.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
}
}
// normalise the position of the event in the rundown order
@@ -499,7 +486,7 @@ export async function editCustomField(
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
updateBackgroundRundown(rundownId, backgroundRundown);
await updateBackgroundRundown(rundownId, backgroundRundown);
}
}
@@ -539,7 +526,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key);
updateBackgroundRundown(rundownId, backgroundRundown);
await updateBackgroundRundown(rundownId, backgroundRundown);
}
}
@@ -22,19 +22,16 @@ import {
dayInMs,
generateId,
getCueCandidate,
createDelay,
createEvent,
createGroup,
createMilestone,
makeString,
validateEndAction,
validateTimerType,
validateTimes,
} from 'ontime-utils';
import {
event as eventDef,
group as groupDef,
delay as delayDef,
milestone as milestoneDef,
} from '../../models/eventsDefinition.js';
import { makeString } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js';
type CompleteEntry<T> =
@@ -61,7 +58,7 @@ export function generateEvent<
const id = eventData.id || getUniqueId(rundown);
if (isOntimeDelay(eventData)) {
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
return createDelay({ duration: eventData.duration ?? 0, id }) as CompleteEntry<T>;
}
// TODO(v4): allow user to provide a larger patch of the group entry
@@ -198,74 +195,6 @@ export function applyPatchToEntry(eventFromRundown: OntimeEntry, patch: Partial<
return { ...eventFromRundown, ...patch } as OntimeDelay;
}
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
* @returns {object|null} - formatted object or null in case is invalid
*/
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
if (Object.keys(eventArgs).length === 0) {
return null;
}
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
const baseEvent = {
id: eventArgs?.id ?? generateId(),
cue,
...eventDef,
};
const event = createEventPatch(baseEvent, eventArgs);
return event;
};
/**
* Creates a new group from an optional patch
*/
export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
if (!patch) {
return { ...groupDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Group,
title: patch.title ?? '',
note: patch.note ?? '',
entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
revision: 0,
timeStart: null,
timeEnd: null,
duration: 0,
isFirstLinked: false,
};
}
/**
* Creates a new milestone from an optional patch
*/
export function createMilestone(patch?: Partial<OntimeMilestone>): OntimeMilestone {
if (!patch) {
return { ...milestoneDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Milestone,
cue: patch.cue ?? '',
title: patch.title ?? '',
note: patch.note ?? '',
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
parent: patch.parent ?? null,
revision: 0,
};
}
/**
* Function infers strategy for a patch with only partial timer data
* @param end
@@ -483,31 +412,6 @@ export function calculateDayOffset(
return 0;
}
/**
* Receives an insertion order and returns the reference to an event ID
* after which we will insert the new event
*/
export function getInsertAfterId(
rundown: Rundown,
parent: OntimeGroup | null,
afterId?: EntryId,
beforeId?: EntryId,
): EntryId | null {
if (afterId) return afterId;
if (!beforeId) return null;
/**
* At this point we know we want to insert before a given ID
* We need to check which list we should use to insert and find the event there
*/
const insertionList = parent ? parent.entries : rundown.order;
if (!insertionList || insertionList.length === 0) return null;
const atIndex = insertionList.findIndex((id) => id === beforeId);
if (atIndex < 1) return null;
return insertionList[atIndex - 1];
}
/**
* Sanitises custom fields in an entry by removing fields
* - if it does not exist in the project
@@ -11,6 +11,7 @@ import { getTimezoneLabel } from '../../utils/time.js';
import { password, routerPrefix } from '../../externals.js';
import { hashPassword } from '../../utils/hash.js';
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
import { portManager } from '../../classes/port-manager/PortManager.js';
const startedAt = new Date();
@@ -37,7 +38,8 @@ export async function getSessionStats(): Promise<SessionStats> {
* Adds business logic to gathering data for the info endpoint
*/
export async function getInfo(): Promise<GetInfo> {
const { version, serverPort } = getDataProvider().getSettings();
const { version } = getDataProvider().getSettings();
const { port } = portManager.getPort();
// get nif and inject localhost
const ni = getNetworkInterfaces();
@@ -46,7 +48,7 @@ export async function getInfo(): Promise<GetInfo> {
return {
networkInterfaces: ni,
version,
serverPort,
serverPort: port,
publicDir: publicDir.root,
};
}
@@ -12,7 +12,6 @@ describe('parseSettings()', () => {
expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
@@ -18,7 +18,6 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
return {
version: defaultSettings.version,
serverPort: data.settings.serverPort ?? defaultSettings.serverPort,
editorKey: data.settings.editorKey ?? defaultSettings.editorKey,
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
@@ -1,25 +1,29 @@
import express from 'express';
import { matchedData } from 'express-validator';
import type { Request, Response } from 'express';
import { deepEqual } from 'fast-equals';
import express from "express";
import { matchedData } from "express-validator";
import type { Request, Response } from "express";
import { deepEqual } from "fast-equals";
import { ErrorResponse, RefetchKey, Settings } from 'ontime-types';
import { getErrorMessage, obfuscate } from 'ontime-utils';
import { ErrorResponse, PortInfo, RefetchKey, Settings } from "ontime-types";
import { getErrorMessage, obfuscate } from "ontime-utils";
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { isDocker } from '../../setup/environment.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import {
validateSettings,
validateWelcomeDialog,
validateServerPort,
} from "./settings.validation.js";
import { getDataProvider } from "../../classes/data-provider/DataProvider.js";
import * as appState from "../../services/app-state-service/AppStateService.js";
import { sendRefetch } from "../../adapters/WebsocketAdapter.js";
import { portManager } from "../../classes/port-manager/PortManager.js";
export const router = express.Router();
router.post('/welcomedialog', validateWelcomeDialog, async (req: Request, res: Response) => {
router.post("/welcomedialog", validateWelcomeDialog, async (req: Request, res: Response) => {
const show = await appState.setShowWelcomeDialog(req.body.show);
res.status(200).json({ show });
});
router.get('/', (_req: Request, res: Response<Settings>) => {
router.get("/", (_req: Request, res: Response<Settings>) => {
const settings = getDataProvider().getSettings();
const obfuscatedSettings = { ...settings };
if (settings.editorKey) {
@@ -33,26 +37,52 @@ router.get('/', (_req: Request, res: Response<Settings>) => {
res.status(200).json(obfuscatedSettings);
});
router.post('/', validateSettings, async (req: Request, res: Response<Settings | ErrorResponse>) => {
router.post(
"/",
validateSettings,
async (req: Request, res: Response<Settings | ErrorResponse>) => {
try {
const data = matchedData<Settings>(req);
const settings = getDataProvider().getSettings();
data.version = settings.version;
if (!deepEqual(data, settings)) {
await getDataProvider().setSettings(data);
sendRefetch(RefetchKey.Settings);
}
res.status(200).json(data);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
},
);
router.get("/serverport", (_req: Request, res: Response<PortInfo | ErrorResponse>) => {
try {
const data = matchedData<Settings>(req);
const settings = getDataProvider().getSettings();
if (isDocker && settings.serverPort !== data.serverPort) {
res.status(403).json({ message: 'Can`t change port when running inside docker' });
return;
}
data.version = settings.version;
if (!deepEqual(data, settings)) {
await getDataProvider().setSettings(data);
sendRefetch(RefetchKey.Settings);
}
res.status(200).json(data);
const { port, pendingRestart } = portManager.getPort();
res.status(200).json({ port, pendingRestart });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
res.status(500).json({ message });
}
});
router.post(
"/serverport",
validateServerPort,
async (req: Request, res: Response<PortInfo | ErrorResponse>) => {
try {
const { serverPort } = matchedData<{ serverPort: number }>(req);
portManager.changePort(serverPort);
const { port, pendingRestart } = portManager.getPort();
res.status(200).json({ port, pendingRestart });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
},
);
@@ -26,7 +26,11 @@ export const validateSettings = [
pinValidator('operatorKey'),
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
body('language').isString().trim().notEmpty(),
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
requestValidationFunction,
];
export const validateServerPort = [
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
requestValidationFunction,
];
@@ -43,18 +43,24 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
try {
const alias = req.params.alias;
const currentPresets = getDataProvider().getUrlPresets();
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
if (!existingPreset) {
throw new Error(`Preset with alias ${alias} does not exist.`);
}
const updatedPreset: URLPreset = {
enabled: req.body.enabled,
alias: req.body.alias,
target: req.body.target,
search: req.body.search,
options: req.body.options ?? existingPreset.options,
};
if (alias !== updatedPreset.alias) {
throw new Error('Changing alias is not permitted');
}
const currentPresets = getDataProvider().getUrlPresets();
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
// Update the URL presets in the data provider
+11 -9
View File
@@ -25,7 +25,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
// Import adapters
import { socket } from './adapters/WebsocketAdapter.js';
import { getDataProvider } from './classes/data-provider/DataProvider.js';
import { getDataProvider, flushPendingWrites } from './classes/data-provider/DataProvider.js';
// Services
import { logger } from './classes/Logger.js';
@@ -46,7 +46,8 @@ import { oscServer } from './adapters/OscAdapter.js';
import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { timerConfig } from './setup/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
import { getNetworkInterfaces } from './utils/network.js';
import { portManager } from './classes/port-manager/PortManager.js';
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -176,16 +177,12 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
*/
export const startServer = async (): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer);
const settings = getDataProvider().getSettings();
const { serverPort: desiredPort } = settings;
expressServer = http.createServer(app);
// the express server must be started before the socket otherwise the on error event listener will not attach properly
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
expressServer = http.createServer(app);
const resultPort = await portManager.attachServer(expressServer);
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
socket.init(expressServer, showWelcome, prefix);
/**
@@ -266,6 +263,10 @@ export const startIntegrations = async () => {
export const shutdown = async (exitCode = 0) => {
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
await flushPendingWrites().catch((_error) => {
/** nothing do to here */
});
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
@@ -274,6 +275,7 @@ export const shutdown = async (exitCode = 0) => {
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
await portManager.shutdown();
}
expressServer?.close();
@@ -180,10 +180,58 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
return db.data;
}
// Module-level state for debounced writes
let pendingWrite: NodeJS.Timeout | null = null;
let activeWrite: Promise<void> | null = null;
const writeDelayMs = 3000; // 3 seconds
/**
* Handles persisting data to file
* Handles persisting data to file with trailing-edge debounce
* Multiple rapid calls will be coalesced into a single write
*/
async function persist() {
if (isTest) return;
// Cancel any pending write and reschedule
if (pendingWrite) {
clearTimeout(pendingWrite);
}
// Schedule new write after quiet period
pendingWrite = setTimeout(async () => {
pendingWrite = null;
// Wait for any in-progress write to finish first
if (activeWrite) {
await activeWrite;
}
try {
activeWrite = db.write();
await activeWrite;
} catch (error) {
console.error('Failed to persist database:', error);
} finally {
activeWrite = null;
}
}, writeDelayMs);
}
/**
* Force immediate write of any pending changes
*/
export async function flushPendingWrites() {
if (isTest) return;
if (pendingWrite) {
clearTimeout(pendingWrite);
pendingWrite = null;
}
// Wait for any in-progress write to finish
if (activeWrite) {
await activeWrite;
}
await db.write();
}
@@ -72,14 +72,12 @@ describe('safeMerge', () => {
it('merges the settings key', () => {
const mergedData = safeMerge(baseDb, {
settings: {
serverPort: 3000,
language: 'pt',
version: 'new',
} as Settings,
});
expect(mergedData.settings).toStrictEqual({
version: 'new',
serverPort: 3000,
operatorKey: null,
editorKey: null,
timeFormat: baseDb.settings.timeFormat,
@@ -0,0 +1,140 @@
import { Server } from "http";
import { config } from "../../setup/config.js";
import { envPort, isDocker, isOntimeCloud } from "../../setup/environment.js";
import * as appState from "../../services/app-state-service/AppStateService.js";
import { logger } from "../Logger.js";
import { LogOrigin, MaybeNumber } from "ontime-types";
import { isAddressInfo, isPortInUseError } from "./PortManager.utils.js";
import { shouldCrashDev } from "../../utils/development.js";
class PortManager {
private static port: number;
private static pendingRestart = false;
private static newPort: MaybeNumber = null;
public getPort() {
return {
port: PortManager.port,
pendingRestart: PortManager.pendingRestart,
newPort: PortManager.newPort,
};
}
/**
* marks that a port change is requested and will be applied on next restart
* @throws if trying to change port inside docker
* @param newPort
* @returns {void}
*/
public changePort(newPort: number): void {
if (isDocker) throw new Error("Can not change port when running inside docker");
if (PortManager.port === newPort) return;
PortManager.newPort = newPort;
PortManager.pendingRestart = true;
}
public migratePortFromProjectFile(port: number) {
shouldCrashDev(
PortManager.port !== undefined,
"this function should not be called after `PortManager.port` has been initialized",
);
appState.setServerPort(port);
}
public async shutdown() {
if (PortManager.pendingRestart && PortManager.newPort != null) {
logger.info(
LogOrigin.Server,
`A port change to ${PortManager.newPort} is pending and will take effect on next start`,
);
await appState.setServerPort(PortManager.newPort);
}
}
/**
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to a random port assigned by the OS
* @param {http.Server} server http server object
* @returns {Promise<number>} the resulting port number
* @throws any other server errors will result in a throw
*/
public async attachServer(server: Server): Promise<number> {
if (isOntimeCloud) {
PortManager.port = await this.forceCloudPort(server);
} else {
PortManager.port =
this.parsePort(envPort) || (await appState.getServerPort()) || config.defaultServerPort;
PortManager.port = await this.tryServerPort(server);
}
await appState.setServerPort(PortManager.port);
return PortManager.port;
}
private parsePort(port: string | undefined) {
if (typeof port !== "string") return null;
if (port === "") return null;
const maybePort = Number(port);
if (isNaN(maybePort)) return null;
return maybePort;
}
private async tryServerPort(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", (error) => {
// we should only move ports if we are in a desktop environment
if (isDocker) {
reject(error);
return;
}
if (!isPortInUseError(error)) {
reject(error);
return;
}
// if we get an address in use error, we will try to open the server in an ephemeral port
// port 0 will assign an ephemeral port
server.listen(0, "0.0.0.0", () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error("Unknown port type, unable to proceed"));
return;
}
logger.error(
LogOrigin.Server,
`Failed to open the desired port: ${PortManager.port} \nMoved to an Ephemeral port: ${address.port}`,
true,
);
resolve(address.port);
});
});
server.listen(PortManager.port, "0.0.0.0", () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error("Unknown port type, unable to proceed"));
return;
}
resolve(address.port);
});
});
}
private forceCloudPort(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", (error) => {
reject(error);
});
server.listen(config.defaultServerPort, "0.0.0.0", () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error("Unknown port type, unable to proceed"));
return;
}
resolve(address.port);
});
});
}
}
export const portManager = new PortManager();
@@ -0,0 +1,15 @@
import { AddressInfo } from 'net';
/**
* Checks whether a given error is a port in use error
*/
export function isPortInUseError(err: Error): boolean {
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE';
}
/**
* Guard verifies that the given address is a usable AddressInfo object
*/
export function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo {
return typeof address === 'object' && address !== null;
}
@@ -0,0 +1,263 @@
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import { Duration, Instant, TimeOfDay } from 'ontime-types';
import { timeNow } from '../../../utils/time.js';
import * as timeCore from '../timeCore.js';
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
// TZ is set to Europe/Copenhagen in vitest.global-setup.ts
// Copenhagen is UTC+1 (CET) in winter and UTC+2 (CEST) in summer
describe('toTimeofDay() converts an instant to local milliseconds since midnight', () => {
const testTimes = [
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
];
test.each(testTimes)('produces the correct local time for $label ($time)', ({ time }) => {
vi.setSystemTime(time);
const result = timeCore.toTimeOfDay(timeCore.now());
expect(result).toBe(timeNow());
});
it('returns a value in the range [0, dayInMs)', () => {
vi.setSystemTime('2025-06-15T23:59:59.999Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(dayInMs);
});
describe('handles DST transitions in Europe/Copenhagen', () => {
it('produces CET time just before spring forward', () => {
// 2025-03-30 at 02:00 CET clocks jump to 03:00 CEST
// UTC 00:58:18 → Copenhagen CET (UTC+1) → local 01:58:18
vi.setSystemTime('2025-03-30T00:58:18Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('01:58:18');
});
it('produces CEST time just after spring forward', () => {
// UTC 01:00:00 → Copenhagen CEST (UTC+2) → local 03:00:00
// 02:00 local does not exist, clocks skip to 03:00
vi.setSystemTime('2025-03-30T01:00:00Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('03:00:00');
});
it('produces CEST time just before fall back', () => {
// 2025-10-26 at 03:00 CEST clocks fall back to 02:00 CET
// UTC 00:59:59 → Copenhagen CEST (UTC+2) → local 02:59:59
vi.setSystemTime('2025-10-26T00:59:59Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('02:59:59');
});
it('produces CET time just after fall back', () => {
// UTC 01:00:00 → Copenhagen CET (UTC+1) → local 02:00:00
vi.setSystemTime('2025-10-26T01:00:00Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('02:00:00');
});
});
});
describe('toInstant() converts a time of day back to an instant anchored to a reference day', () => {
const testTimes = [
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
];
test.each(testTimes)('roundtrips through toTimeofDay for $label ($time)', ({ time }) => {
vi.setSystemTime(time);
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
describe('roundtrips through DST transitions in Europe/Copenhagen', () => {
it('roundtrips just before spring forward', () => {
vi.setSystemTime('2025-03-30T00:58:18Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just after spring forward', () => {
vi.setSystemTime('2025-03-30T01:00:00Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just before fall back', () => {
vi.setSystemTime('2025-10-26T00:59:59Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just after fall back', () => {
vi.setSystemTime('2025-10-26T01:00:00Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
});
});
describe('timeSince() returns the duration elapsed since a past point', () => {
it('measures elapsed time between two instants', () => {
const start = 1000 as Instant;
const end = 5000 as Instant;
expect(timeCore.timeSince(end, start)).toBe(4000);
});
it('returns negative when the reference is in the future', () => {
const start = 5000 as Instant;
const end = 1000 as Instant;
expect(timeCore.timeSince(end, start)).toBe(-4000);
});
});
describe('timeUntil() returns the duration until a future point', () => {
it('measures time remaining until a future instant', () => {
const current = 1000 as Instant;
const target = 5000 as Instant;
expect(timeCore.timeUntil(current, target)).toBe(4000);
});
it('returns negative when the target is in the past', () => {
const current = 5000 as Instant;
const target = 1000 as Instant;
expect(timeCore.timeUntil(current, target)).toBe(-4000);
});
});
describe('addDuration() moves a point in time by a duration', () => {
it('moves an instant forward', () => {
const instant = 1000 as Instant;
const duration = 500 as Duration;
expect(timeCore.addDuration(instant, duration)).toBe(1500);
});
it('moves backward with a negative duration', () => {
const instant = 1000 as Instant;
const duration = -300 as Duration;
expect(timeCore.addDuration(instant, duration)).toBe(700);
});
it('moves by the sum of multiple durations', () => {
const instant = 1000 as Instant;
const durations = [500, -300, 50] as Duration[];
expect(timeCore.addDuration(instant, durations)).toBe(1250);
});
it('keeps the instant unchanged with an empty duration list', () => {
const instant = 1000 as Instant;
expect(timeCore.addDuration(instant, [])).toBe(1000);
});
});
describe('elapsedTime() calculates duration between two times of day', () => {
it('calculates elapsed time on the same day', () => {
const start = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
const clock = (10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:30
expect(timeCore.elapsedTime(clock, start)).toBe(30 * MILLIS_PER_MINUTE);
});
it('calculates elapsed time when crossing midnight (overnight)', () => {
const start = (23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:50
const clock = (21 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:21
// From 23:50 to 00:21 = 31 minutes
expect(timeCore.elapsedTime(clock, start)).toBe(31 * MILLIS_PER_MINUTE);
});
it('returns 0 when start and clock are the same', () => {
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
expect(timeCore.elapsedTime(time, time)).toBe(0);
});
it('calculates correctly for just after midnight', () => {
const start = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
const clock = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
// From 23:59 to 00:01 = 2 minutes
expect(timeCore.elapsedTime(clock, start)).toBe(2 * MILLIS_PER_MINUTE);
});
});
describe('daysSinceStart() calculates full days elapsed since a start epoch', () => {
it('returns 0 when current epoch equals start epoch', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const epoch = timeCore.now();
expect(timeCore.daysSinceStart(epoch, epoch)).toBe(0);
});
it('returns 0 when less than one day has elapsed', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T18:00:00Z'); // 8 hours later
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0);
});
it('returns 1 when crossing midnight once', () => {
// Copenhagen is UTC+1 in winter, so 22:50 UTC = 23:50 local
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T23:21:00Z'); // 00:21 local next day
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
});
it('returns 2 when crossing midnight twice', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-17T15:00:00Z'); // 2 days + 5 hours later
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(2);
});
it('handles overnight start correctly', () => {
// Copenhagen is UTC+1 in winter
// Start at 23:50 local (22:50 UTC), check at 00:10 local next day (23:10 UTC)
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T23:10:00Z'); // 00:10 local next day
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
/** Returns the current instant */
export function now(): Instant {
return Date.now() as Instant;
}
/**
* Converts an instant to milliseconds since midnight in the local timezone
* - Accounts for the system's timezone offset including DST
* - Result is always in the range [0, dayInMs)
*/
export function toTimeOfDay(instant: Instant): TimeOfDay {
const tzOffset = new Date(instant).getTimezoneOffset() * MILLIS_PER_MINUTE;
return ((((instant - tzOffset) % dayInMs) + dayInMs) % dayInMs) as TimeOfDay;
}
/**
* Returns the current time of day in milliseconds since midnight
*/
export function timeOfDayNow(): TimeOfDay {
return toTimeOfDay(now());
}
/**
* Converts a time of day to an instant anchored to the same day as the reference
* - Uses the reference instant to determine which calendar day to anchor to
*/
export function toInstant(clock: TimeOfDay, reference: Instant): Instant {
const referenceClock = toTimeOfDay(reference);
const dayStart = reference - referenceClock;
return (dayStart + clock) as Instant;
}
/**
* Returns the duration elapsed since a past instant
* Result is positive when 'since' is before 'now'
*/
export function timeSince(now: Instant, since: Instant): Duration {
return (now - since) as Duration;
}
/**
* Returns the duration remaining until a future instant
* Result is positive when 'until' is after 'now'
*/
export function timeUntil(now: Instant, until: Instant): Duration {
return (until - now) as Duration;
}
/**
* Moves an instant forward or backward by a duration
* Use a negative duration to move backward
*/
export function addDuration(instant: Instant, duration: Duration | Duration[]): Instant {
const totalDuration = Array.isArray(duration) ? duration.reduce((total, current) => total + current, 0) : duration;
return (instant + totalDuration) as Instant;
}
/**
* Calculates elapsed time on the clock from a starting time to the current time
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
*/
export function elapsedTime(current: TimeOfDay, start: TimeOfDay): Duration {
return (current < start ? current + dayInMs - start : current - start) as Duration;
}
/**
* Calculates the number of full days elapsed since a start epoch
* Uses the start time-of-day to determine day boundaries
*/
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
const startClock = toTimeOfDay(startEpoch);
const elapsedMs = currentEpoch - startEpoch;
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
}
+1 -2
View File
@@ -25,7 +25,6 @@ const dbModel: DatabaseModel = {
},
settings: {
version: ONTIME_VERSION,
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
@@ -41,7 +40,7 @@ const dbModel: DatabaseModel = {
customFields: {},
automation: {
enabledAutomations: true,
enabledOscIn: true,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [],
automations: {},

Some files were not shown because too many files have changed in this diff Show More