Compare commits

...

20 Commits

Author SHA1 Message Date
Carlos Valente 224d3600b2 fix: prevent race conditions on build (#2036)
* fix: prevent race conditions on build

* refactor: upload artifacts and keep for 1 day

* chore: remove deprecated settings

* Revert "refactor: icon compatibility with macos tahoe"

This reverts commit a7a78702b1.
2026-03-25 11:03:41 +01:00
Carlos Valente 7d469038bf fix: enable cjs interop while we migrate packages (#2034) 2026-03-25 10:49:43 +01:00
Carlos Valente 1c78397f18 chore: bump version to 4.6.0 (#2023) 2026-03-24 11:13:44 +01:00
Alex Christoffer Rasmussen 73eff20d08 chore: format (#2030)
* chore: format

* chore: add formatter in CI check

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-24 09:49:22 +01:00
Carlos Valente 7e3aaa8c30 feat: UI access for custom views (#2020)
* style: consistent casing on menu

* refactor: bundle demo from code

* feat: allow uploading custom views
2026-03-24 09:19:45 +01:00
Carlos Valente 95536902f5 feat: Improve automation flow and add ontime playback actions (#2025) (#2024)
* fix: prevent add filter from submitting form

* refactor: event clarifies whether automations exist but are disabled

* refactor: improve readability of automation form

* refactor: add affordance for field warnings

* refactor: improve visibility of automation off state

* refactor: apply warning styles to other feature toggles

* Adding playback actions to automations (#2024)

* adding intial automation actions

* cleaning up

* fixing formatting

* ran oxfmt

* switching action names to playback- to match the dropdown strings

* fixing flicker that was caused by scroll arrows gettting unmounted

---------

Co-authored-by: Cameron Slipp <cdslipp@gmail.com>
2026-03-24 09:13:37 +01:00
Alex Christoffer Rasmussen cf206e6220 feat: Vite 8 (#2018)
* chore: upgrade vite

* fix: inconsistentCjsInterop for ReactQR

* chore: e2e tests should use relativve path and get host from playwright config

* refactor: drop qr code dependency

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-24 09:11:57 +01:00
Alex Christoffer Rasmussen 4662cc8a26 feat: improve smart time entry (#2029)
Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-23 21:23:36 +01:00
Cameron Slipp 8c52556cc0 chore: oxc info to DEVELOPMENT.md (#2027)
* Adding info on formatting and linting

* fixing md links
2026-03-23 15:57:14 +01:00
Carlos Valente b1da877dc7 refactor: consistent style for warning 2026-03-22 20:47:00 +01:00
Carlos Valente d8cef11a25 fix: server port settings has lifecycle 2026-03-22 20:47:00 +01:00
Carlos Valente ef5c0a371e refactor: improve app quit in windows 2026-03-21 18:50:17 +01:00
Carlos Valente b2b7855114 refactor: improve css editor loading suspense 2026-03-21 09:38:02 +01:00
Carlos Valente 129efc33b9 chore: upgrade prism dependencies 2026-03-21 09:38:02 +01:00
Joel Wetzell 6a40c66a7b upgrade vite and related dependencies (#2003) 2026-03-20 20:04:39 +01:00
Carlos Valente 2806177c72 fix: prevent wrapping in time inputs 2026-03-17 21:21:34 +01:00
Carlos Valente 4408bdb0d3 refactor: improve shutdown cleanup 2026-03-17 20:11:45 +01:00
Alex Christoffer Rasmussen 871a6b46c8 feat: automatic refetch css override (#1588)
* feat: send refect on css change

* feat: revamp ViewLoader

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-17 20:08:33 +01:00
Carlos Valente fd39cab845 fix: prevent sheet name too long in excel export 2026-03-16 19:05:32 +01:00
Carlos Valente 1cee679397 fix: pending roll sets group expected times as due 2026-03-14 08:43:22 +01:00
122 changed files with 4472 additions and 2336 deletions
+31
View File
@@ -44,6 +44,16 @@ jobs:
run: pnpm dist-mac --env-mode=loose
timeout-minutes: 60
- name: Upload macOS build artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: ontime-macos-build
path: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
with:
@@ -73,6 +83,16 @@ jobs:
- name: Electron - Build app
run: pnpm dist-win
- name: Upload Windows build artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: ontime-windows-build
path: |
./apps/electron/dist/ontime-win64.exe
./apps/electron/dist/win-unpacked/
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
with:
@@ -100,6 +120,17 @@ jobs:
- name: Electron - Build app
run: pnpm dist-linux
- name: Upload Linux build artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: ontime-linux-build
path: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
with:
+3
View File
@@ -33,6 +33,9 @@ jobs:
run: pnpm install --frozen-lockfile
# Run code quality
- name: Run formatter
run: pnpm format:check
- name: Run linter
run: pnpm lint
+1
View File
@@ -38,6 +38,7 @@ dist/
# bundled assets
translations.json
override.css
**/external/demo/index.html
# working stuff
**/TODO.md
+7
View File
@@ -99,6 +99,13 @@ Other useful commands
- **List running processes** by running `docker ps`
- **Kill running process** by running `docker kill <process-id>`
## FORMATTING AND LINTING
Ontime uses [oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) for formatting and [oxlint](https://oxc.rs/docs/guide/usage/linter.html) for linting. Look for an [oxc extension](https://oxc.rs/docs/guide/usage/linter/editors) for your editor of choice.
- **Run formatter** by running `pnpm format`
- **Run linter** by running `pnpm lint`
## CONTRIBUTION GUIDELINES
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.5.0",
"version": "4.6.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+11 -9
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.5.0",
"version": "4.6.0",
"private": true,
"type": "module",
"dependencies": {
@@ -19,13 +19,13 @@
"axios": "^1.12.2",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.11.0",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.17.0",
@@ -57,20 +57,22 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@types/prismjs": "^1.26.5",
"@sentry/vite-plugin": "5.1.1",
"@types/prismjs": "^1.26.6",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "4.5.1",
"@vitejs/plugin-react": "5.2.0",
"babel-plugin-react-compiler": "1.0.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "6.3.1",
"vite-plugin-compression2": "2.2.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-prismjs-plus": "1.1.1",
"vite-plugin-svgr": "4.5.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "catalog:"
}
}
+3 -2
View File
@@ -3,9 +3,11 @@ import { serverURL } from '../../externals';
// keys in tanstack store
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_SERVER_PORT = ['appServerPort'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const CUSTOM_VIEWS = ['customViews'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
@@ -13,6 +15,7 @@ export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
@@ -21,9 +24,7 @@ export const TRANSLATION = ['translation'];
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
+58
View File
@@ -0,0 +1,58 @@
import axios from 'axios';
import { type CustomViewsListResponse, type MessageResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const customViewsPath = `${apiEntryUrl}/custom-views`;
export async function getCustomViews(options?: RequestOptions): Promise<CustomViewsListResponse> {
const response = await axios.get<CustomViewsListResponse>(customViewsPath, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.shortTimeout,
});
return response.data;
}
export async function uploadCustomView(slug: string, file: File, options?: RequestOptions): Promise<MessageResponse> {
const formData = new FormData();
formData.append('indexHtml', file);
return (
await axios.post<MessageResponse>(`${customViewsPath}/${encodeURIComponent(slug)}/upload`, formData, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
})
).data;
}
export async function downloadCustomView(slug: string, options?: RequestOptions): Promise<void> {
const response = await axios.get(`${customViewsPath}/${encodeURIComponent(slug)}/download`, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
downloadBlob(response.data, `${slug}-index.html`);
}
export async function restoreDemoView(options?: RequestOptions): Promise<MessageResponse> {
return (
await axios.post<MessageResponse>(`${customViewsPath}/restore-demo`, null, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
})
).data;
}
export async function deleteCustomView(slug: string, options?: RequestOptions): Promise<void> {
await axios.delete(`${customViewsPath}/${encodeURIComponent(slug)}`, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
});
}
+2 -2
View File
@@ -31,8 +31,8 @@ export async function postShowWelcomeDialog(show: boolean) {
/**
* HTTP request to retrieve server port
*/
export async function getServerPort(): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`);
export async function getServerPort(options?: RequestOptions): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`, { signal: options?.signal });
return res.data;
}
@@ -0,0 +1,22 @@
@use '@/theme/viewerDefs' as *;
.square {
border-radius: $component-border-radius-sm;
background-color: $viewer-color;
display: flex;
align-items: center;
justify-content: center;
}
.blink {
animation: blink 1s ease-in-out infinite alternate;
}
@keyframes blink {
0% {
opacity: 10%;
}
100% {
opacity: 100%;
}
}
@@ -0,0 +1,43 @@
import { toString } from 'qrcode';
import { Suspense, use } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './QrCode.module.scss';
interface QRCodeProps {
value: string;
size: number;
}
export default function QRCode({ value, size }: QRCodeProps) {
'use memo';
const svgPromise = toString(value, {
width: size,
margin: 2,
color: { dark: '#101010', light: '#cfcfcf' },
});
return (
<Suspense fallback={<QrFallback size={size} />}>
<QrSvg svgPromise={svgPromise} size={size} />
</Suspense>
);
}
function QrFallback({ size }: { size: number }) {
'use memo';
return <span style={{ width: size + 2, height: size + 2 }} className={cx([style.blink, style.square])} />;
}
function QrSvg({ svgPromise, size }: { svgPromise: Promise<string>; size: number }) {
'use memo';
const svg = use(svgPromise);
return (
<span
className={style.square}
style={{ width: size + 2, height: size + 2 }}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
@@ -125,6 +125,11 @@
font-size: 0.5rem;
display: grid;
place-content: center;
visibility: hidden;
&[data-visible] {
visibility: visible;
}
&::before {
content: '';
@@ -29,7 +29,7 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} keepMounted />
<BaseSelect.Popup className={styles.popup}>
<BaseSelect.Arrow />
<BaseSelect.List className={styles.list}>
@@ -43,7 +43,7 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
))}
</BaseSelect.List>
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} keepMounted />
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
@@ -1,9 +1,17 @@
.tag {
font-size: calc(1rem - 3px);
letter-spacing: 0.5px;
background-color: $gray-900;
color: $ui-white;
border-radius: 2px;
padding: 0 0.25rem;
white-space: nowrap;
}
.default {
background-color: $gray-900;
color: $ui-white;
}
.warning {
background-color: $orange-1300;
color: $orange-300;
}
@@ -1,11 +1,14 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Tag.module.scss';
interface TagProps {
className?: string;
variant?: 'default' | 'warning';
}
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
return <span className={cx([style.tag, style[variant], className])}>{children}</span>;
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getCSSContents } from '../api/assets';
import { CSS_OVERRIDE } from '../api/constants';
export default function useCssOverride(enabled: boolean) {
const { data, status } = useQuery({
queryKey: CSS_OVERRIDE,
queryFn: ({ signal }) => getCSSContents({ signal }),
staleTime: MILLIS_PER_HOUR,
enabled,
});
return {
data: data ?? '',
status,
};
}
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { type CustomViewsListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_VIEWS } from '../api/constants';
import { getCustomViews } from '../api/customViews';
const placeholderCustomViews: CustomViewsListResponse = {
views: [],
};
export default function useCustomViews() {
const { data, status, refetch } = useQuery({
queryKey: CUSTOM_VIEWS,
queryFn: ({ signal }) => getCustomViews({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? placeholderCustomViews, status, refetch };
}
@@ -0,0 +1,36 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { PortInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SERVER_PORT } from '../api/constants';
import { getServerPort, postServerPort } from '../api/settings';
import { ontimeQueryClient } from '../queryClient';
const serverPortPlaceholder: PortInfo = {
port: 4001,
pendingRestart: false,
};
export default function useServerPort() {
const { data, status, isError, refetch } = useQuery<PortInfo>({
queryKey: APP_SERVER_PORT,
queryFn: ({ signal }) => getServerPort({ signal }),
placeholderData: (previousData) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
const { mutateAsync } = useMutation({
mutationFn: async (serverPort: number) => {
const response = await postServerPort(serverPort);
return response.data;
},
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: APP_SERVER_PORT });
},
onSuccess: (newData) => {
ontimeQueryClient.setQueryData(APP_SERVER_PORT, newData);
},
});
return { data: data ?? serverPortPlaceholder, status, isError, refetch, mutateAsync };
}
+5 -5
View File
@@ -202,20 +202,20 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 for this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
actualGroupStart: state.rundown.actualGroupStart,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
phase: state.timer.phase,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// 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,
actualStart: state.rundown.actualStart,
plannedStart: state.rundown.plannedStart,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
phase: state.timer.phase,
}));
/* ======================= View specific subscriptions ======================= */
+4
View File
@@ -13,6 +13,7 @@ import { isProduction, websocketUrl } from '../../externals';
import {
APP_SETTINGS,
CLIENT_LIST,
CSS_OVERRIDE,
CUSTOM_FIELDS,
PROJECT_DATA,
REPORT,
@@ -199,6 +200,9 @@ export const connectSocket = () => {
case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break;
case RefetchKey.CssOverride:
ontimeQueryClient.invalidateQueries({ queryKey: CSS_OVERRIDE });
break;
case RefetchKey.Translation:
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
break;
+4
View File
@@ -0,0 +1,4 @@
declare module 'virtual:prismjs' {
const prism: typeof import('prismjs');
export default prism;
}
@@ -140,11 +140,21 @@ $inner-padding: 1rem;
font-size: 1rem;
}
.fieldHeading {
display: flex;
align-items: center;
gap: 0.5rem;
}
.fieldDescription {
font-size: calc(1rem - 2px);
color: $gray-400;
}
.warningText {
color: $orange-500;
}
.fieldError {
font-size: calc(1rem - 2px);
color: $red-500;
@@ -85,18 +85,28 @@ export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
export function Field({
title,
description,
error,
descriptionTone = 'default',
}: {
title: ReactNode;
description: ReactNode;
error?: string;
descriptionTone?: 'default' | 'warning';
}) {
return (
<div className={style.fieldTitle}>
{title}
<div className={style.fieldHeading}>{title}</div>
{error && <Error>{error}</Error>}
{!error && description && <Description>{description}</Description>}
{!error && description && <Description tone={descriptionTone}>{description}</Description>}
</div>
);
}
export function Description({ children }: { children: ReactNode }) {
return <div className={style.fieldDescription}>{children}</div>;
export function Description({ children, tone = 'default' }: { children: ReactNode; tone?: 'default' | 'warning' }) {
return <div className={cx([style.fieldDescription, tone === 'warning' && style.warningText])}>{children}</div>;
}
export function Highlight({ children }: { children: ReactNode }) {
@@ -282,7 +282,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
);
})}
<div>
<Button type='submit' onClick={handleAddNewFilter}>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div>
@@ -13,6 +13,8 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
const isLoading = status === 'pending';
const automationState = isLoading ? undefined : data.enabledAutomations;
const oscInputState = isLoading ? undefined : data.enabledOscIn;
return (
<>
@@ -24,13 +26,15 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
automationState={automationState}
oscInputState={oscInputState}
/>
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} />
<AutomationsList automations={data.automations} enabledAutomations={automationState} />
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} />
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
</div>
</Panel.Section>
</>
@@ -7,6 +7,7 @@ import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import Tag from '../../../../common/components/tag/Tag';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
@@ -18,12 +19,16 @@ interface AutomationSettingsProps {
enabledAutomations: boolean;
enabledOscIn: boolean;
oscPortIn: number;
automationState?: boolean;
oscInputState?: boolean;
}
export default function AutomationSettingsForm({
enabledAutomations,
enabledOscIn,
oscPortIn,
automationState,
oscInputState,
}: AutomationSettingsProps) {
const {
handleSubmit,
@@ -56,6 +61,8 @@ export default function AutomationSettingsForm({
};
const canSubmit = !isSubmitting && isDirty && isValid;
const automationsEnabled = watch('enabledAutomations');
const oscInputEnabled = watch('enabledOscIn');
return (
<Panel.Card>
@@ -102,33 +109,52 @@ export default function AutomationSettingsForm({
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Enable automations'
description='Allow Ontime to send messages on lifecycle triggers'
title={
<>
<span>Enable automations</span>
{automationState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
automationState === false
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
: 'Allow Ontime to send messages on lifecycle triggers'
}
descriptionTone={automationState === false ? 'warning' : 'default'}
error={errors.enabledAutomations?.message}
/>
<Switch
size='large'
checked={watch('enabledAutomations')}
checked={automationsEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup>
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
<Panel.ListItem>
<Panel.Field
title='OSC input'
description='Allow control of Ontime through OSC'
title={
<>
<span>OSC input</span>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
oscInputState === false
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
: 'Allow control of Ontime through OSC'
}
descriptionTone={oscInputState === false ? 'warning' : 'default'}
error={errors.enabledOscIn?.message}
/>
<Switch
size='large'
checked={watch('enabledOscIn')}
checked={oscInputEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
}
@@ -6,6 +6,7 @@ import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -20,10 +21,11 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps {
automations: NormalisedAutomation;
enabledAutomations?: boolean;
}
export default function AutomationsList(props: AutomationsListProps) {
const { automations } = props;
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -56,6 +58,13 @@ export default function AutomationsList(props: AutomationsListProps) {
<Panel.Divider />
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</Info>
)}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
@@ -66,6 +66,11 @@ export default function OntimeActionForm({
{ value: 'aux2-set', label: 'Aux 2: set' },
{ value: 'aux3-set', label: 'Aux 3: set' },
{ value: 'playback-start', label: 'Playback: start' },
{ value: 'playback-stop', label: 'Playback: stop' },
{ value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message: source' },
]}
@@ -5,6 +5,7 @@ import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils';
@@ -14,10 +15,11 @@ import TriggersListItem from './TriggersListItem';
interface TriggersListProps {
triggers: Trigger[];
automations: NormalisedAutomation;
enabledAutomations?: boolean;
}
export default function TriggersList(props: TriggersListProps) {
const { triggers, automations } = props;
const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -52,6 +54,11 @@ export default function TriggersList(props: TriggersListProps) {
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
)}
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can performance issues.
@@ -0,0 +1,114 @@
import { ChangeEvent, FormEvent, useMemo, useRef, useState } from 'react';
import { IoCloudUploadOutline } from 'react-icons/io5';
import { uploadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import * as Panel from '../../panel-utils/PanelUtils';
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
import style from './CustomViews.module.scss';
interface CustomViewFormProps {
onComplete: () => void;
onClose: () => void;
}
export default function CustomViewForm({ onComplete, onClose }: CustomViewFormProps) {
const [slug, setSlug] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [slugDirty, setSlugDirty] = useState(false);
const [fileDirty, setFileDirty] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const normalisedSlug = useMemo(() => slug.trim().toLowerCase(), [slug]);
const previewUrl = getViewUrl(normalisedSlug);
const slugError = useMemo(() => getSlugError(normalisedSlug), [normalisedSlug]);
const fileError = useMemo(() => getFileError(selectedFile), [selectedFile]);
const canUpload = Boolean(normalisedSlug && selectedFile) && !slugError && !fileError && !isUploading;
const handleSelectFile = (event: ChangeEvent<HTMLInputElement>) => {
setSelectedFile(event.target.files?.[0] ?? null);
setFileDirty(true);
setError(null);
};
const handleUpload = async (event: FormEvent) => {
event.preventDefault();
if (!selectedFile || slugError || fileError) return;
try {
setIsUploading(true);
setError(null);
await uploadCustomView(normalisedSlug, selectedFile);
onComplete();
} catch (err) {
setError(maybeAxiosError(err));
} finally {
setIsUploading(false);
}
};
return (
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleSelectFile}
accept='.html,text/html'
/>
<div className={style.step}>
<div className={style.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description>
<Input
value={slug}
onChange={(event) => {
setSlug(event.target.value);
setSlugDirty(true);
}}
placeholder='my-view'
aria-label='Custom view name'
autoCapitalize='off'
autoComplete='off'
fluid
/>
<Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description>
<Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div>
<div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}>
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
</Button>
<span className={style.fileName}>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
</span>
</Panel.InlineElements>
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -0,0 +1,51 @@
.uploadForm {
display: flex;
flex-direction: column;
gap: 1rem;
}
.step {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stepTitle {
font-weight: 600;
color: $gray-200;
}
.filePicker {
min-height: 2rem;
}
.fileName {
color: $gray-300;
font-size: calc(1rem - 2px);
}
.missing {
color: $warning-orange;
}
.urlCell {
font-size: calc(1rem - 2px);
color: $gray-300;
user-select: all;
white-space: nowrap;
}
.actionsHeader {
text-align: right;
}
.actionsCell {
width: 1%;
white-space: nowrap;
text-align: right;
}
.actionsGroup {
width: 100%;
justify-content: flex-end;
}
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { restoreDemoView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useCustomViews from '../../../../common/hooks-query/useCustomViews';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViewForm from './CustomViewForm';
import { customViewsDocs } from './customViews.utils';
import CustomViewsList from './CustomViewsList';
export default function CustomViews() {
const { data, refetch, status } = useCustomViews();
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const hasDemoView = data.views.some((view) => view.slug === 'demo');
const handleUploadComplete = async () => {
setIsUploadOpen(false);
await refetch();
};
const handleRestoreDemo = async () => {
try {
setActionError(null);
await restoreDemoView();
await refetch();
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom views
<Panel.InlineElements>
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
Restore demo
</Button>
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>
Upload one <strong>index.html</strong> per view to <strong>/external/&lt;name&gt;/</strong>.
<br />
External imports are not allowed, include all assets inside the html file.
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<CustomViewsList
views={data.views}
onOpenUpload={() => setIsUploadOpen(true)}
onMutate={() => refetch()}
onError={setActionError}
/>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,33 @@
import { CustomViewSummary } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViewsListItem from './CustomViewsListItem';
import style from './CustomViews.module.scss';
interface CustomViewsListProps {
views: CustomViewSummary[];
onOpenUpload: () => void;
onMutate: () => void;
onError: (message: string) => void;
}
export default function CustomViewsList({ views, onOpenUpload, onMutate, onError }: CustomViewsListProps) {
return (
<Panel.Table>
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th className={style.actionsHeader} />
</tr>
</thead>
<tbody>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
{views.map((view, index) => (
<CustomViewsListItem key={view.slug} slug={view.slug} index={index} onMutate={onMutate} onError={onError} />
))}
</tbody>
</Panel.Table>
);
}
@@ -0,0 +1,75 @@
import { IoDownloadOutline, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { deleteCustomView, downloadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import IconButton from '../../../../common/components/buttons/IconButton';
import { handleLinks } from '../../../../common/utils/linkUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import { getViewUrl } from './customViews.utils';
import style from './CustomViews.module.scss';
interface CustomViewsListItemProps {
slug: string;
index: number;
onMutate: () => void;
onError: (message: string) => void;
}
export default function CustomViewsListItem({ slug, index, onMutate, onError }: CustomViewsListItemProps) {
const handlePreview = () => {
handleLinks(`external/${encodeURIComponent(slug)}/`);
};
const handleDownload = async () => {
try {
await downloadCustomView(slug);
} catch (error) {
onError(maybeAxiosError(error));
}
};
const handleDelete = async () => {
try {
await deleteCustomView(slug);
onMutate();
} catch (error) {
onError(maybeAxiosError(error));
}
};
return (
<tr>
<td>{slug}</td>
<td className={style.urlCell}>{getViewUrl(slug)}</td>
<td className={style.actionsCell}>
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
<IconButton
variant='ghosted-white'
onClick={handlePreview}
aria-label='Preview custom view'
data-testid={`custom-view__preview_${index}`}
>
<IoOpenOutline />
</IconButton>
<IconButton
variant='ghosted-white'
onClick={handleDownload}
aria-label='Download custom view'
data-testid={`custom-view__download_${index}`}
>
<IoDownloadOutline />
</IconButton>
<IconButton
variant='ghosted-destructive'
onClick={handleDelete}
aria-label='Delete custom view'
data-testid={`custom-view__delete_${index}`}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</td>
</tr>
);
}
@@ -0,0 +1,39 @@
import { baseURI, serverURL } from '../../../../externals';
export const customViewsDocs = 'https://docs.getontime.no/features/custom-views/';
const customViewSlugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const maxUploadBytes = 4_000_000;
export const maxUploadLabel = `${maxUploadBytes / 1_000_000}MB`;
export function getSlugError(slug: string): string | null {
if (!slug) {
return 'Name is required.';
}
if (!customViewSlugPattern.test(slug)) {
return 'Use lowercase letters, numbers, and dashes only.';
}
return null;
}
export function getFileError(file: File | null): string | null {
if (!file) {
return 'index.html is required.';
}
if (file.name.toLowerCase() !== 'index.html') {
return 'Only index.html uploads are supported.';
}
if (file.size === 0) {
return 'Uploaded file is empty.';
}
if (file.size > maxUploadBytes) {
return `File size limit (${maxUploadLabel}) exceeded.`;
}
return null;
}
export function getViewUrl(slug: string): string {
const url = new URL(serverURL);
const path = slug ? `external/${encodeURIComponent(slug)}/` : 'external/';
url.pathname = baseURI ? `${baseURI}/${path}` : `/${path}`;
return url.toString();
}
@@ -1,12 +1,11 @@
import { PortInfo } from 'ontime-types';
import { useCallback, useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { getServerPort, postServerPort } 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 Tag from '../../../../common/components/tag/Tag';
import useServerPort from '../../../../common/hooks-query/useServerPort';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -16,29 +15,22 @@ interface ServerPortForm {
}
export default function ServerPortSettings() {
const { data, status, isError, refetch, mutateAsync } = useServerPort();
const {
handleSubmit,
register,
reset,
setError,
clearErrors,
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]);
reset({ serverPort: data.port });
}, [data.pendingRestart, data.port, reset]);
const onSubmit = async (formData: ServerPortForm) => {
if (formData.serverPort < 1024 || formData.serverPort > 65535) {
@@ -46,21 +38,27 @@ export default function ServerPortSettings() {
return;
}
try {
await postServerPort(formData.serverPort);
setPort(await getServerPort());
clearErrors('root');
await mutateAsync(formData.serverPort);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const onReset = async () => {
try {
setPort(await getServerPort());
} catch (error) {
clearErrors('root');
const result = await refetch();
if (result.isError) {
setError('root', { message: 'Failed to load server port' });
return;
}
reset({ serverPort: result.data?.port ?? data.port });
};
const rootError = isError ? 'Failed to load server port' : errors.root?.message;
return (
<Panel.Section
as='form'
@@ -72,7 +70,6 @@ export default function ServerPortSettings() {
<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>
@@ -88,9 +85,13 @@ export default function ServerPortSettings() {
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
{data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
@@ -2,6 +2,7 @@ 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 CustomViews from '../manage-panel/CustomViews';
import GeneralSettings from './GeneralSettings';
import ProjectData from './ProjectData';
import ServerPortSettings from './ServerPortSettings';
@@ -10,8 +11,9 @@ 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);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
return (
<>
@@ -25,6 +27,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={viewRef}>
<ViewSettings />
</div>
<div ref={customViewsRef}>
<CustomViews />
</div>
{!isDocker && (
<div ref={portRef}>
<ServerPortSettings />
@@ -9,6 +9,7 @@ import Info from '../../../../common/components/info/Info';
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import Tag from '../../../../common/components/tag/Tag';
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -55,6 +56,8 @@ export default function ViewSettings() {
reset(data);
};
const overrideStylesEnabled = watch('overrideStyles');
if (!control) {
return null;
}
@@ -91,12 +94,22 @@ export default function ViewSettings() {
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={codeEditorHandler.close} />
<Panel.ListItem>
<Panel.Field
title='Override CSS styles'
description='Enables overriding view styles with custom stylesheet'
title={
<>
<span>Override CSS styles</span>
{overrideStylesEnabled && <Tag variant='warning'>ON</Tag>}
</>
}
description={
overrideStylesEnabled
? 'CSS override is ON. Ontime views will use the custom override stylesheet.'
: 'Enables overriding view styles with custom stylesheet'
}
descriptionTone={overrideStylesEnabled ? 'warning' : 'default'}
/>
<Switch
size='large'
checked={watch('overrideStyles')}
checked={overrideStylesEnabled}
onCheckedChange={(value: boolean) => setValue('overrideStyles', value, { shouldDirty: true })}
/>
<Button onClick={codeEditorHandler.open} disabled={isSubmitting}>
@@ -1,4 +1,5 @@
.wrapper {
min-height: 500px;
max-height: 500px;
overflow-y: auto;
}
@@ -1,71 +1,38 @@
import Prism from 'prismjs/components/prism-core';
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
import { memo } from 'react';
import Editor from 'react-simple-code-editor';
import 'prismjs/components/prism-css';
import 'prismjs/themes/prism-tomorrow.min.css';
import Prism from 'virtual:prismjs';
import style from './StyleEditor.module.scss';
interface CodeEditorProps {
language: string;
initialValue: string;
isDirty: boolean;
setIsDirty: (value: boolean) => void;
value: string;
onChange: (value: string) => void;
}
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
const { language, initialValue, isDirty, setIsDirty } = props;
const [code, setCode] = useState(initialValue);
export default memo(CodeEditor);
function CodeEditor({ language, onChange, value }: CodeEditorProps) {
const highlight = (code: string) => {
const grammar = Prism.languages[language];
return grammar ? Prism.highlight(code, grammar, language) : code;
};
const handleChange = (newCode: string) => {
setCode(newCode);
};
useImperativeHandle(cssRef, () => {
return {
getCss: () => code,
};
});
// add contents to editor on mount and any change in initialValue
useEffect(() => {
setCode(initialValue);
}, [initialValue]);
// handle dirty state on change
useEffect(() => {
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
setIsDirty(true);
}
if (initialValue.trim() === code.trim() && isDirty) {
setIsDirty(false);
}
}, [initialValue, code, isDirty, setIsDirty]);
return (
<div className={style.wrapper}>
<Editor
value={code}
value={value}
padding={15}
onValueChange={handleChange}
onValueChange={onChange}
highlight={highlight}
style={{
fontFamily: 'monospace',
fontSize: 12,
minHeight: 500,
background: '#2d2d2d', // Background of tomorrow theme
background: 'transparent',
}}
/>
</div>
);
});
CodeEditor.displayName = 'StyleEditor';
export default memo(CodeEditor);
}
@@ -13,3 +13,12 @@
gap: 1rem;
flex-direction: column;
}
.editorBody {
position: relative;
min-height: 500px;
background-color: $gray-1350;
border: 1px solid $gray-1100;
border-radius: 3px;
overflow: hidden;
}
@@ -1,4 +1,4 @@
import { Suspense, lazy, useEffect, useRef, useState } from 'react';
import { Suspense, lazy, useEffect, useState } from 'react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
import Button from '../../../../../common/components/buttons/Button';
@@ -15,24 +15,21 @@ interface CodeEditorModalProps {
onClose: () => void;
}
interface CSSRef {
getCss: () => string;
}
export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProps) {
const [css, setCSS] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [savedCss, setSavedCss] = useState('');
const [draftCss, setDraftCss] = useState('');
const [isLoadingCss, setIsLoadingCss] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cssRef = useRef<CSSRef>(null);
const isDirty = savedCss.trim() !== draftCss.trim();
const handleRestore = async () => {
try {
setResetLoading(true);
const defaultCss = await restoreCSSContents();
setCSS(defaultCss);
setDraftCss(defaultCss);
} catch (_error) {
/** no error handling for now */
} finally {
@@ -43,11 +40,8 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
const handleSave = async () => {
try {
setSaveLoading(true);
if (cssRef.current) {
await postCSSContents(cssRef.current.getCss());
setCSS(cssRef.current.getCss());
setIsDirty(false);
}
await postCSSContents(draftCss);
setSavedCss(draftCss);
} catch (_error) {
/** no error handling for now */
} finally {
@@ -55,22 +49,43 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
}
};
const clear = () => setCSS('');
const clear = () => setDraftCss('');
useEffect(() => {
let isCancelled = false;
async function fetchServerCSS() {
// check for isOpen to fetch recent css
if (isOpen) {
try {
setError(null);
setIsLoadingCss(true);
const css = await getCSSContents();
setCSS(css);
if (isCancelled) {
return;
}
setSavedCss(css);
setDraftCss(css);
} catch (_error) {
if (isCancelled) {
return;
}
setSavedCss('');
setDraftCss('');
setError('Failed to load CSS from server');
/** no error handling for now */
} finally {
if (!isCancelled) {
setIsLoadingCss(false);
}
}
}
}
fetchServerCSS();
return () => {
isCancelled = true;
};
}, [isOpen]);
return (
@@ -81,9 +96,14 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
showCloseButton
showBackdrop
bodyElements={
<Suspense fallback={null}>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</Suspense>
<div className={style.editorBody}>
{!isLoadingCss && (
<Suspense fallback={<Panel.Loader isLoading />}>
<CodeEditor value={draftCss} onChange={setDraftCss} language='css' />
</Suspense>
)}
<Panel.Loader isLoading={isLoadingCss} />
</div>
}
footerElements={
<div className={style.column}>
@@ -1,5 +0,0 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -19,7 +19,8 @@ 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' },
{ id: 'settings__custom-views', label: 'Custom views' },
{ id: 'settings__port', label: 'Server port' },
],
},
{
@@ -208,26 +208,28 @@ export function MetadataTimes() {
}
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
const active = isPlaybackActive(playback);
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
// the group end time dose not encode any day offsets so it is calculated with group start time and duration
// the group end time does not encode any day offsets so it is calculated with group start time and duration
const plannedGroupEnd = (() => {
if (!active) return null;
if (!hasRunningTimer) return null;
if (!group || group.timeStart === null) return null;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? group.timeStart + group.duration - normalizedClock
: actualGroupStart + group.duration - normalizedClock;
if (mode === OffsetMode.Absolute) {
return group.timeStart + group.duration - normalizedClock;
}
if (actualGroupStart === null) return null;
return actualGroupStart + group.duration - normalizedClock;
})();
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
const expectedGroupEnd = hasRunningTimer && groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
return (
@@ -238,7 +240,7 @@ function GroupTimes() {
<span
className={cx([
style.time,
(!group || !active) && style.muted,
(!group || !hasRunningTimer) && style.muted,
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
@@ -250,7 +252,7 @@ function GroupTimes() {
<span
className={cx([
style.time,
!groupExpectedEnd && style.muted,
expectedGroupEnd === null && style.muted,
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
@@ -262,25 +264,27 @@ function GroupTimes() {
}
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
const active = isPlaybackActive(playback);
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
const plannedFlagStart = (() => {
if (!active) return null;
if (!hasRunningTimer) return null;
if (!entry) return null;
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? normalizedTimeStart - normalizedClock
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
if (mode === OffsetMode.Absolute) {
return normalizedTimeStart - normalizedClock;
}
if (actualStart === null || plannedStart === null) return null;
return normalizedTimeStart + actualStart - plannedStart - normalizedClock;
})();
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntil = hasRunningTimer && expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
const title = entry?.title ?? null;
@@ -294,7 +298,7 @@ function FlagTimes() {
data-testid='flag-plannedStart'
className={cx([
style.time,
(!entry || !active) && style.muted,
(!entry || !hasRunningTimer) && style.muted,
plannedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
+16 -1
View File
@@ -13,6 +13,7 @@ import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import useAutomationSettings from '../../common/hooks-query/useAutomationSettings';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata';
import { AppMode } from '../../ontimeConfig';
@@ -49,6 +50,8 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
// invoke the compiler for the component
'use memo';
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata);
@@ -263,6 +266,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
isPast={entryMetadata.isPast}
eventIndex={entryMetadata.eventIndex}
data={entry}
automationsEnabled={automationsEnabled}
loaded={entryMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
@@ -282,7 +286,18 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
</Fragment>
);
},
[entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup],
[
entries,
metadata,
getIsCollapsed,
isEditMode,
cursor,
nextEventId,
playback,
lastEntryId,
handleCollapseGroup,
automationsEnabled,
],
);
if (sortableData.length < 1) {
@@ -8,6 +8,7 @@ interface RundownEntryProps {
type: SupportedEntry;
isPast: boolean;
data: OntimeEntry;
automationsEnabled?: boolean;
loaded: boolean;
eventIndex: number;
hasCursor: boolean;
@@ -22,6 +23,7 @@ interface RundownEntryProps {
export default function RundownEntry({
isPast,
data,
automationsEnabled,
loaded,
hasCursor,
isNext,
@@ -52,6 +54,7 @@ export default function RundownEntry({
title={data.title}
note={data.note}
delay={data.delay}
automationsEnabled={automationsEnabled}
colour={data.colour}
isPast={isPast}
isNext={isNext}
@@ -1,30 +1,90 @@
.triggerForm {
padding-block: 0.5rem;
display: grid;
grid-template-columns: 8rem 1fr auto 2rem;
.triggers {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section,
.formSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sectionTitle {
font-size: $aux-text-size;
color: $label-gray;
}
.triggerList {
display: flex;
flex-direction: column;
background-color: $black-10;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
overflow: hidden;
}
.triggerForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.formFields {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
align-items: end;
}
.formActions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
min-height: 2rem;
}
.trigger {
padding: 0.25rem 0.5rem;
padding: 0.5rem 0.75rem;
display: grid;
grid-template-columns: 8rem 1fr 2rem;
align-items: center;
min-height: 2.5rem;
&:nth-child(even) {
background-color: $white-1;
}
& > span {
width: fit-content;
&:not(:first-child) {
border-top: 1px solid $white-10;
}
}
.errorLabel {
.triggerMeta {
min-width: 0;
}
.metaLabel {
color: $label-gray;
font-size: $aux-text-size;
line-height: 1.2;
margin-bottom: 0.125rem;
}
.automationTitle {
overflow-wrap: anywhere;
}
.validationError,
.validationSuccess {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: $aux-text-size;
}
.validationError {
color: $red-500;
}
.success {
.validationSuccess {
color: $green-500;
}
@@ -1,13 +1,13 @@
import { TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { Fragment, useCallback, useMemo, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants';
@@ -20,23 +20,36 @@ interface EventEditorTriggersProps {
}
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
const showTriggers = triggers.length > 0;
return (
<>
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
<EventTriggerForm triggers={triggers} eventId={eventId} />
</>
<div className={style.triggers}>
{automationsEnabled === false && (
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
)}
{showTriggers && (
<div className={style.section}>
<div className={style.sectionTitle}>Applied automations</div>
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</div>
)}
<Editor.Panel className={style.formSection}>
<div className={style.sectionTitle}>Add automation</div>
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</Editor.Panel>
</div>
);
}
interface EventTriggerFormProps {
eventId: string;
triggers?: Trigger[];
automations: NormalisedAutomation;
}
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings();
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
const { updateEntry } = useEntryActionsContext();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -52,7 +65,7 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
if (automationId === undefined) {
return 'Select an automation';
}
if (!Object.keys(automationSettings.automations).includes(automationId)) {
if (!Object.keys(automations).includes(automationId)) {
return 'This automation does not exist';
}
if (triggers === undefined) {
@@ -64,10 +77,11 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
};
const validationError = getValidationError(cycleValue, automationId);
const validationLabel = validationError ?? 'Ready to add automation';
const triggerOptions = useMemo(
() => [
{ value: null, label: 'Select Trigger' },
{ value: null, label: 'Select lifecycle' },
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
],
[], // eventTriggerOptions is a constant, no need for dependency
@@ -76,42 +90,49 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const automationOptions = useMemo(
() => [
{ value: null, label: 'Select Automation' },
...Object.values(automationSettings.automations).map(({ id, title }) => ({ value: id, label: title })),
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
],
[automationSettings.automations], // This needs to be a dependency as it can change
[automations], // This needs to be a dependency as it can change
);
return (
<div className={style.triggerForm}>
<Select
value={cycleValue}
onValueChange={(value) => {
if (value !== null) setCycleValue(value);
}}
options={triggerOptions}
/>
<div className={style.formFields}>
<div>
<Editor.Label>Lifecycle</Editor.Label>
<Select
value={cycleValue}
onValueChange={(value) => {
if (value !== null) setCycleValue(value);
}}
options={triggerOptions}
/>
</div>
<Select
value={automationId ?? null}
onValueChange={(value) => {
if (value !== null) setAutomationId(value);
}}
options={automationOptions}
/>
<div>
<Editor.Label>Automation</Editor.Label>
<Select
value={automationId ?? null}
onValueChange={(value) => {
if (value !== null) setAutomationId(value);
}}
options={automationOptions}
/>
</div>
</div>
<Button
disabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add
</Button>
{validationError !== undefined ? (
<Tooltip text={validationError} render={<span />}>
<IoAlertCircle className={style.errorLabel} />
</Tooltip>
) : (
<IoCheckmarkCircle className={style.success} />
)}
<div className={style.formActions}>
<div className={validationError ? style.validationError : style.validationSuccess}>
{validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
<span>{validationLabel}</span>
</div>
<Button
disabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add automation
</Button>
</div>
</div>
);
}
@@ -119,11 +140,11 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
interface ExistingEventTriggersProps {
eventId: string;
triggers: Trigger[];
automations: NormalisedAutomation;
}
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActionsContext();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback(
(triggerId: string) => {
@@ -144,16 +165,22 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps
});
return (
<div>
<div className={style.triggerList}>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => {
const { id, automationId } = trigger;
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
return (
<div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag>
<div className={style.triggerMeta}>
<div className={style.metaLabel}>Lifecycle</div>
<div>{triggerLifeCycle}</div>
</div>
<div className={style.triggerMeta}>
<div className={style.metaLabel}>Automation</div>
<div className={style.automationTitle}>{automationTitle}</div>
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
<IoTrash />
</IconButton>
@@ -231,6 +231,10 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.warning {
color: $orange-500;
}
.statusIcon.disabled {
color: $gray-1000;
}
@@ -43,6 +43,7 @@ interface RundownEventProps {
title: string;
note: string;
delay: number;
automationsEnabled?: boolean;
colour: string;
isPast: boolean;
isNext: boolean;
@@ -76,6 +77,7 @@ export default function RundownEvent({
title,
note,
delay,
automationsEnabled,
colour,
isPast,
isNext,
@@ -302,6 +304,7 @@ export default function RundownEvent({
title={title}
note={note}
delay={delay}
automationsEnabled={automationsEnabled}
isNext={isNext}
skip={skip}
loaded={loaded}
@@ -38,6 +38,7 @@ interface RundownEventInnerProps {
title: string;
note: string;
delay: number;
automationsEnabled?: boolean;
isNext: boolean;
skip: boolean;
loaded: boolean;
@@ -64,6 +65,7 @@ function RundownEventInner({
title,
note,
delay,
automationsEnabled,
isNext,
skip = false,
loaded,
@@ -79,6 +81,21 @@ function RundownEventInner({
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
const automationTooltip = (() => {
if (!hasTriggers) {
return 'Event has no triggers';
}
if (automationsEnabled !== false) {
return 'Event has triggers';
}
return 'Event has triggers, but automations are disabled';
})();
const automationIconClasses = cx([
style.statusIcon,
hasTriggers ? (automationsEnabled === false ? style.warning : style.active) : style.disabled,
]);
const playBtnStyles = { _hover: {} };
if (!skip && eventIsPlaying) {
@@ -142,8 +159,8 @@ function RundownEventInner({
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
</Tooltip>
<Tooltip text='Event has Triggers' render={<span />}>
<IoFlash className={`${style.statusIcon} ${hasTriggers ? style.active : style.disabled}`} />
<Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} />
</Tooltip>
</div>
</div>
@@ -1,9 +1,3 @@
.qrCode {
padding: 0.5rem;
background: $ui-white;
border-radius: 3px;
}
.column {
margin-top: 1rem;
display: flex;
@@ -2,7 +2,6 @@ import { OntimeView, URLPreset } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { useRef, useState } from 'react';
import { FieldErrors, useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
import { generateUrl } from '../../common/api/session';
import { maybeAxiosError } from '../../common/api/utils';
@@ -10,6 +9,7 @@ import Button from '../../common/components/buttons/Button';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import Info from '../../common/components/info/Info';
import Input from '../../common/components/input/input/Input';
import QRCode from '../../common/components/qr-code/QrCode';
import Select from '../../common/components/select/Select';
import Switch from '../../common/components/switch/Switch';
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
@@ -275,7 +275,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
</div>
<Panel.Section className={style.column}>
<Panel.Description>Share this link</Panel.Description>
<QRCode size={172} value={url} className={style.qrCode} />
<QRCode size={172} value={url} />
<div className={style.copiableLink} data-testid='copy-link'>
{url}
</div>
+26 -22
View File
@@ -1,35 +1,39 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, Suspense } from 'react';
import { overrideStylesURL } from '../common/api/constants';
import useCssOverride from '../common/hooks-query/useCssOverride';
import useViewSettings from '../common/hooks-query/useViewSettings';
import { useRuntimeStylesheet } from '../common/hooks/useRuntimeStylesheet';
import Loader from './common/loader/Loader';
export default function ViewLoader({ children }: PropsWithChildren) {
const { data } = useViewSettings();
const { shouldRender } = useRuntimeStylesheet(data.overrideStyles ? overrideStylesURL : undefined);
const scriptTagId = 'ontime-stylesheet-override';
function OverrideStyles() {
'use memo';
const { data: settings } = useViewSettings();
const { overrideStyles } = settings;
const { data: css } = useCssOverride(overrideStyles);
const cssBlob = URL.createObjectURL(new Blob([css], { type: 'text/css' }));
//@ts-expect-error disabled exists on link when rel='stylesheet' https://react.dev/reference/react-dom/components/link#props
return <link id={scriptTagId} rel='stylesheet' href={cssBlob} precedence='high' disabled={!overrideStyles} />;
}
export default function ViewLoader({ children }: PropsWithChildren) {
'use memo';
// we need to be able to override the background colour with the key param
const searchParams = new URLSearchParams(window.location.search);
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
// eventually we would want to leverage suspense here
// while the feature is not ready, we simply trigger a loader
// suspense would have the advantage of being triggered also by react-query
if (!shouldRender) {
return (
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Loader />
</>
);
}
return (
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Suspense
fallback={
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Loader />
</>
}
>
<OverrideStyles />
{children}
</>
</Suspense>
);
}
@@ -179,12 +179,6 @@
flex: 1;
}
&__qr {
padding: 0.5rem;
background-color: $ui-white;
border-radius: 2px;
}
&__img {
padding: 0.5rem;
border-radius: 2px;
@@ -2,9 +2,9 @@ import { useViewportSize } from '@mantine/hooks';
import { OntimeView, ProjectData } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { useEffect, useMemo, useState } from 'react';
import QRCode from 'react-qr-code';
import ProgressBar from '../../common/components/progress-bar/ProgressBar';
import QRCode from '../../common/components/qr-code/QrCode';
import Empty from '../../common/components/state/Empty';
import EmptyPage from '../../common/components/state/EmptyPage';
import TitleCard from '../../common/components/title-card/TitleCard';
@@ -171,7 +171,7 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
{extraInfo && <ExtraInfo projectData={projectData} size={qrSize} source={extraInfo} />}
<div className='info-card'>
{projectData.url && <QRCode value={projectData.url} size={qrSize} level='L' className='info-card__qr' />}
{projectData.url && <QRCode value={projectData.url} size={qrSize} />}
{projectData.info && <div className='info-card__message'>{projectData.info}</div>}
</div>
</div>
@@ -4,6 +4,7 @@
background-color: transparent;
border-radius: $component-border-radius-md;
text-wrap: nowrap;
white-space: nowrap;
display: flex;
align-items: center;
+2 -1
View File
@@ -9,7 +9,8 @@
],
"types": [
"vite/client",
"vite-plugin-svgr/client"
"vite-plugin-svgr/client",
"vite-plugin-prismjs-plus/client",
],
"module": "esnext",
"moduleResolution": "bundler",
+11 -13
View File
@@ -4,6 +4,7 @@ import { sentryVitePlugin } from '@sentry/vite-plugin';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { compression } from 'vite-plugin-compression2';
import prismjsPlugin from 'vite-plugin-prismjs-plus';
import svgrPlugin from 'vite-plugin-svgr';
import { ONTIME_VERSION } from './src/ONTIME_VERSION';
@@ -16,6 +17,11 @@ const ReactCompilerConfig = {
export default defineConfig({
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
legacy: {
// Temporary compatibility for CJS packages affected by Vite 8 interop changes.
// ie: react-simple-code-editor
inconsistentCjsInterop: true,
},
define: {
// we pass along the NODE_ENV here in case it is a docker build
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
@@ -48,6 +54,11 @@ export default defineConfig({
algorithm: 'brotliCompress',
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
}),
prismjsPlugin({
manual: true,
languages: ['css'],
css: true,
}),
],
server: {
port: 3000,
@@ -88,25 +99,12 @@ export default defineConfig({
build: {
outDir: './build',
sourcemap: true,
rollupOptions: {
output: {
manualChunks(id) {
// Split vendor code
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
esbuild: {
legalComments: 'none',
},
css: {
preprocessorOptions: {
scss: {
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.5.0",
"version": "4.6.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -14,7 +14,7 @@
"main": "src/main.js",
"devDependencies": {
"electron": "38.2.1",
"electron-builder": "26.8.1",
"electron-builder": "26.0.18",
"wait-on": "^7.2.0"
},
"scripts": {
@@ -29,7 +29,8 @@
"appId": "no.lightdev.ontime",
"asar": true,
"dmg": {
"artifactName": "ontime-macOS-${arch}.dmg"
"artifactName": "ontime-macOS-${arch}.dmg",
"icon": "icon.icns"
},
"mac": {
"notarize": true,
@@ -45,7 +46,7 @@
]
},
"category": "public.app-category.productivity",
"icon": "ontime.icon"
"icon": "icon.icns"
},
"win": {
"target": "nsis"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

@@ -1,62 +0,0 @@
{
"fill-specializations" : [
{
"value" : "system-dark"
},
{
"appearance" : "dark",
"value" : "system-dark"
}
],
"groups" : [
{
"layers" : [
{
"blend-mode-specializations" : [
{
"appearance" : "dark",
"value" : "normal"
}
],
"glass-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : false
},
{
"appearance" : "tinted",
"value" : false
}
],
"hidden" : false,
"image-name" : "ontime-icon.png",
"name" : "ontime-icon",
"position" : {
"scale" : 1,
"translation-in-points" : [
-0.5,
-113
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+4 -4
View File
@@ -28,7 +28,7 @@ const {
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, showDialog, download) {
const template = [
...(isMac ? [makeMacMenu(askToQuit)] : []),
makeFileMenu(serverUrl, redirectWindow, showDialog, download),
makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download),
makeEditMenu(),
makeViewMenu(clientUrl),
makeSettingsMenu(redirectWindow),
@@ -56,7 +56,7 @@ function makeMacMenu(askToQuit) {
{
label: 'Quit',
click: askToQuit,
accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
accelerator: 'Cmd+Q',
},
],
};
@@ -86,7 +86,7 @@ function makeEditMenu() {
* @param {function} download - function to download a resource from url
* @returns {Object}
*/
function makeFileMenu(serverUrl, redirectWindow, showDialog, download) {
function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download) {
const downloadProject = () => {
try {
download(serverUrl + downloadPath);
@@ -134,7 +134,7 @@ function makeFileMenu(serverUrl, redirectWindow, showDialog, download) {
],
},
{ type: 'separator' },
{ role: isMac ? 'close' : 'quit' },
isMac ? { role: 'close' } : { label: 'Quit', click: askToQuit, accelerator: 'Alt+F4' },
],
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.5.0",
"version": "4.6.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.5.0",
"version": "4.6.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -48,7 +48,7 @@
"dev:inspect": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json --inspect ./src/index.ts",
"lint": "oxlint --quiet --type-aware",
"typecheck": "tsc -p tsconfig.app.json --noEmit",
"prebuild": "tsx --tsconfig tsconfig.app.json ./scripts/bundleCss.ts && tsx --tsconfig tsconfig.app.json ./scripts/bundleTranslation.ts",
"prebuild": "tsx --tsconfig tsconfig.app.json ./scripts/bundleDefaults.ts",
"build": "node esbuild.js",
"test": "cross-env IS_TEST=true vitest",
"test:inspect": "cross-env IS_TEST=true vitest --inspect --no-file-parallelism",
-20
View File
@@ -1,20 +0,0 @@
import { writeFile } from 'node:fs/promises';
import path from 'path';
import { defaultCss } from '../src/user/styles/bundledCss';
/**
* Script to write contents of bundledCss to override.css
*/
async function bundleCss() {
try {
const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles');
const cssFile = path.resolve(stylesDir, 'override.css');
await writeFile(cssFile, defaultCss, { encoding: 'utf8' });
} catch (error) {
console.error('Failed writing to CSS file: ', error);
}
}
bundleCss();
+31
View File
@@ -0,0 +1,31 @@
import path from 'path';
import { defaultCss } from '../src/bundle/bundledCss';
import { defaultDemoHtml } from '../src/bundle/bundledDemoHtml';
import { defaultTranslation } from '../src/bundle/bundledTranslations';
import { ensureDirectory, writeToFile } from '../src/utils/fileManagement';
const srcDir = path.resolve(process.cwd(), 'src');
const bundles = [
{ file: path.resolve(srcDir, 'user', 'styles', 'override.css'), content: defaultCss, label: 'CSS' },
{
file: path.resolve(srcDir, 'user', 'translations', 'translations.json'),
content: defaultTranslation,
label: 'Translation',
},
{ file: path.resolve(srcDir, 'external', 'demo', 'index.html'), content: defaultDemoHtml, label: 'Demo HTML' },
];
async function bundleDefaults() {
for (const { file, content, label } of bundles) {
try {
ensureDirectory(path.dirname(file));
await writeToFile(file, content, { encoding: 'utf8' });
} catch (error) {
console.error(`Failed writing ${label} file: `, error);
}
}
}
bundleDefaults();
-20
View File
@@ -1,20 +0,0 @@
import { writeFile } from 'node:fs/promises';
import path from 'path';
import { defaultTranslation } from '../src/user/translations/bundledTranslations.js';
/**
* Script to write contents of default translation to translation.json
*/
async function bundleTranslation() {
try {
const translationDir = path.resolve(process.cwd(), 'src', 'user', 'translations');
const translationsFile = path.resolve(translationDir, 'translations.json');
await writeFile(translationsFile, defaultTranslation, { encoding: 'utf8' });
} catch (error) {
console.error('Failed writing to translations file: ', error);
}
}
bundleTranslation();
+1 -1
View File
@@ -1,3 +1,3 @@
export interface IAdapter {
shutdown: () => void;
shutdown: () => Promise<void>;
}
+9 -2
View File
@@ -72,10 +72,17 @@ class OscServer implements IAdapter {
});
this.udpSocket.bind(port);
}
shutdown() {
shutdown(): Promise<void> {
logger.info(LogOrigin.Rx, 'OSC: Closing server');
this.udpSocket?.close();
const socket = this.udpSocket;
this.udpSocket = null;
if (!socket) {
return Promise.resolve();
}
return new Promise((resolve) => {
socket.close(() => resolve());
});
}
}
+19 -2
View File
@@ -235,8 +235,25 @@ class SocketServer implements IAdapter {
}
}
shutdown() {
this.wss?.close();
shutdown(): Promise<void> {
const wss = this.wss;
if (!wss) {
return Promise.resolve();
}
return new Promise((resolve) => {
// Notify clients first so they can reconnect gracefully
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
client.close(1001, 'Server shutting down');
}
}
wss.close(() => {
this.wss = null;
resolve();
});
});
}
}
@@ -4,7 +4,7 @@ import { type ErrorResponse, RefetchKey } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { defaultCss } from '../../user/styles/bundledCss.js';
import { defaultCss } from '../../bundle/bundledCss.js';
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
@@ -24,6 +24,7 @@ router.post('/css', validatePostCss, async (req: Request, res: Response<never |
const { css } = req.body;
try {
await writeCssFile(css);
sendRefetch(RefetchKey.CssOverride);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -34,6 +35,7 @@ router.post('/css', validatePostCss, async (req: Request, res: Response<never |
router.post('/css/restore', async (_req: Request, res: Response<string | ErrorResponse>) => {
try {
await writeCssFile(defaultCss);
sendRefetch(RefetchKey.CssOverride);
res.status(200).send(defaultCss);
} catch (error) {
const message = getErrorMessage(error);
@@ -3,8 +3,8 @@ import { readFile, writeFile } from 'node:fs/promises';
import type { TranslationObject } from 'ontime-types';
import { defaultCss } from '../../bundle/bundledCss.js';
import { publicFiles } from '../../setup/index.js';
import { defaultCss } from '../../user/styles/bundledCss.js';
/**
* Reads the user's css file
@@ -4,6 +4,7 @@ import { parseUserTime } from 'ontime-utils';
import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/message.service.js';
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
export function toOntimeAction(action: OntimeAction) {
const actionType = action.action;
@@ -40,6 +41,16 @@ export function toOntimeAction(action: OntimeAction) {
return auxTimerService.setTime(time, 3);
}
// Playback actions
case 'playback-start':
return runtimeService.start();
case 'playback-stop':
return runtimeService.stop();
case 'playback-pause':
return runtimeService.pause();
case 'playback-roll':
return runtimeService.roll();
// Message actions
case 'message-set': {
messageService.patch({
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { CustomViewError } from '../customViews.errors.js';
import { isValidCustomViewSlug, resolveCustomViewDirectory, validateHtmlContent } from '../customViews.service.js';
describe('isValidCustomViewSlug()', () => {
it('accepts valid slugs', () => {
expect(isValidCustomViewSlug('a')).toBe(true);
expect(isValidCustomViewSlug('my-view-1')).toBe(true);
expect(isValidCustomViewSlug('example123')).toBe(true);
});
it('rejects empty or too long', () => {
expect(isValidCustomViewSlug('')).toBe(false);
expect(isValidCustomViewSlug('a'.repeat(64))).toBe(false);
});
it('rejects invalid characters', () => {
expect(isValidCustomViewSlug('Hello')).toBe(false);
expect(isValidCustomViewSlug('my_view')).toBe(false);
expect(isValidCustomViewSlug('my view')).toBe(false);
expect(isValidCustomViewSlug('../escape')).toBe(false);
});
it('rejects leading or trailing hyphens', () => {
expect(isValidCustomViewSlug('-start')).toBe(false);
expect(isValidCustomViewSlug('end-')).toBe(false);
});
});
describe('resolveCustomViewDirectory()', () => {
it('throws CustomViewError on invalid slugs', () => {
expect(() => resolveCustomViewDirectory('Hello-World')).toThrow(CustomViewError);
expect(() => resolveCustomViewDirectory('../escape')).toThrow(CustomViewError);
expect(() => resolveCustomViewDirectory('')).toThrow(CustomViewError);
});
});
describe('validateHtmlContent()', () => {
it('accepts valid HTML with doctype', () => {
expect(() => validateHtmlContent('<!DOCTYPE html><html><body>hello</body></html>')).not.toThrow();
});
it('accepts valid HTML starting with html tag', () => {
expect(() => validateHtmlContent('<html><body>hello</body></html>')).not.toThrow();
});
it('accepts HTML with inline script and style', () => {
const html = '<!DOCTYPE html><html><head><style>body{}</style></head><body><script>alert(1)</script></body></html>';
expect(() => validateHtmlContent(html)).not.toThrow();
});
it('rejects content that is not HTML', () => {
expect(() => validateHtmlContent('just some text')).toThrow(CustomViewError);
expect(() => validateHtmlContent('{"json": true}')).toThrow(CustomViewError);
});
it('rejects external script imports', () => {
const html = '<!DOCTYPE html><html><body><script src="https://cdn.example.com/app.js"></script></body></html>';
expect(() => validateHtmlContent(html)).toThrow('External scripts are not allowed');
});
it('rejects external stylesheets', () => {
const html = '<!DOCTYPE html><html><head><link rel="stylesheet" href="styles.css"></head><body></body></html>';
expect(() => validateHtmlContent(html)).toThrow('External stylesheets are not allowed');
});
it('rejects iframes', () => {
const html = '<!DOCTYPE html><html><body><iframe src="https://example.com"></iframe></body></html>';
expect(() => validateHtmlContent(html)).toThrow('Iframes are not allowed');
});
it('allows link tags that are not stylesheets', () => {
const html = '<!DOCTYPE html><html><head><link rel="icon" href="data:,"></head><body></body></html>';
expect(() => validateHtmlContent(html)).not.toThrow();
});
});
@@ -0,0 +1,22 @@
import type { Response } from 'express';
import type { ErrorResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
export class CustomViewError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = 'CustomViewError';
}
}
export function handleCustomViewsError(error: unknown, res: Response<ErrorResponse>) {
if (error instanceof CustomViewError) {
res.status(error.statusCode).send({ message: error.message });
return;
}
res.status(500).send({ message: getErrorMessage(error) });
}
@@ -0,0 +1,48 @@
import type { NextFunction, Request, Response } from 'express';
import multer from 'multer';
import type { ErrorResponse } from 'ontime-types';
import { customViewMaxFileSize } from './customViews.service.js';
const allowedMimeTypes = new Set(['text/html', 'application/xhtml+xml', 'application/octet-stream']);
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: customViewMaxFileSize,
files: 1,
},
fileFilter: (_req, file, cb) => {
if (allowedMimeTypes.has(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Unsupported file type "${file.mimetype}"`));
}
},
}).single('indexHtml');
export function uploadCustomViewFile(req: Request, res: Response<ErrorResponse>, next: NextFunction) {
upload(req, res, (error: unknown) => {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
res.status(413).send({ message: `File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded` });
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_UNEXPECTED_FILE') {
res.status(400).send({ message: 'Unexpected upload field. Use "indexHtml"' });
return;
}
if (error instanceof Error) {
res.status(400).send({ message: error.message });
return;
}
res.status(400).send({ message: 'Could not process upload request' });
});
}
@@ -0,0 +1,72 @@
import express from 'express';
import type { Request, Response } from 'express';
import { type CustomViewsListResponse, type ErrorResponse, type MessageResponse } from 'ontime-types';
import { handleCustomViewsError } from './customViews.errors.js';
import { uploadCustomViewFile } from './customViews.middleware.js';
import {
deleteCustomView,
getCustomViewDownloadPath,
listCustomViews,
restoreDemoView,
uploadCustomView,
} from './customViews.service.js';
import { validateCustomViewSlugParam } from './customViews.validation.js';
export const router = express.Router();
router.get('/', async (_req: Request, res: Response<CustomViewsListResponse | ErrorResponse>) => {
try {
const views = await listCustomViews();
res.status(200).send({ views });
} catch (error) {
handleCustomViewsError(error, res);
}
});
router.post('/restore-demo', async (_req: Request, res: Response<MessageResponse | ErrorResponse>) => {
try {
const view = await restoreDemoView();
res.status(201).send({ message: `Restored demo view "${view.slug}"` });
} catch (error) {
handleCustomViewsError(error, res);
}
});
router.post(
'/:slug/upload',
validateCustomViewSlugParam,
uploadCustomViewFile,
async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
try {
const view = await uploadCustomView(req.params.slug, req.file);
res.status(201).send({ message: `Uploaded custom view "${view.slug}"` });
} catch (error) {
handleCustomViewsError(error, res);
}
},
);
router.get('/:slug/download', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
try {
const pathToFile = await getCustomViewDownloadPath(req.params.slug);
const fileName = `${req.params.slug}-index.html`;
res.download(pathToFile, fileName, (error: Error | null) => {
if (error && !res.headersSent) {
res.status(500).send({ message: 'Could not download custom view' });
}
});
} catch (error) {
handleCustomViewsError(error, res);
}
});
router.delete('/:slug', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
try {
await deleteCustomView(req.params.slug);
res.status(204).send();
} catch (error) {
handleCustomViewsError(error, res);
}
});
@@ -0,0 +1,175 @@
import { join, resolve } from 'node:path';
import { type CustomViewSummary } from 'ontime-types';
import { defaultDemoHtml } from '../../bundle/bundledDemoHtml.js';
import { publicDir } from '../../setup/index.js';
import {
createDirectory,
deleteDirectory,
ensureDirectory,
fileIsReadable,
isNodeError,
readDirectoryEntries,
replaceDirectory,
statIfExists,
writeToFile,
} from '../../utils/fileManagement.js';
import { CustomViewError } from './customViews.errors.js';
/**
* Patterns that indicate external resource loading.
* Custom views must be self-contained single HTML files with only inline CSS and JavaScript.
*/
const forbiddenHtmlPatterns: { pattern: RegExp; message: string }[] = [
{ pattern: /<script[^>]+src\s*=/i, message: 'External scripts are not allowed. Use inline <script> instead.' },
{
pattern: /<link[^>]+rel\s*=\s*["']?stylesheet["']?/i,
message: 'External stylesheets are not allowed. Use inline <style> instead.',
},
{ pattern: /<iframe[\s>]/i, message: 'Iframes are not allowed.' },
];
export function validateHtmlContent(content: string): void {
const htmlDoctype = /^\s*<!doctype\s+html[\s>]/i;
const htmlTag = /^\s*<html[\s>]/i;
if (!htmlDoctype.test(content) && !htmlTag.test(content)) {
throw new CustomViewError(
'File does not appear to be valid HTML. Expected <!DOCTYPE html> or <html> at the start.',
400,
);
}
for (const { pattern, message } of forbiddenHtmlPatterns) {
if (pattern.test(content)) {
throw new CustomViewError(message, 400);
}
}
}
const allowedSlugChars = /^[a-z0-9-]+$/;
export function isValidCustomViewSlug(slug: string): boolean {
if (typeof slug !== 'string') return false;
if (slug.length < 1 || slug.length > 63) return false;
if (!allowedSlugChars.test(slug)) return false;
if (slug.startsWith('-') || slug.endsWith('-')) return false;
return true;
}
export function resolveCustomViewDirectory(slug: string): string {
if (!isValidCustomViewSlug(slug)) {
throw new CustomViewError('Invalid name. Use lowercase letters, numbers, and dashes only.', 400);
}
return resolve(publicDir.externalDir, slug);
}
export function getPathToCustomView(slug: string): string {
return join(resolveCustomViewDirectory(slug), customViewIndexFilename);
}
export interface CustomViewUploadFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
export const customViewMaxFileSize = 4_000_000; // 4MB
export const customViewIndexFilename = 'index.html';
export function validateCustomViewUpload(file: CustomViewUploadFile | undefined): CustomViewUploadFile {
if (!file) {
throw new CustomViewError('File not found', 422);
}
const fileName = file.originalname.trim().toLowerCase();
if (fileName !== customViewIndexFilename) {
throw new CustomViewError('Only index.html uploads are supported', 400);
}
if (file.size === 0) {
throw new CustomViewError('Uploaded file is empty', 400);
}
if (file.size > customViewMaxFileSize) {
throw new CustomViewError(`File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded`, 413);
}
const content = file.buffer.toString('utf-8');
validateHtmlContent(content);
return file;
}
export async function listCustomViews(): Promise<CustomViewSummary[]> {
ensureDirectory(publicDir.externalDir);
const entries = await readDirectoryEntries(publicDir.externalDir);
const views: CustomViewSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || !isValidCustomViewSlug(entry.name)) continue;
const indexStats = await statIfExists(getPathToCustomView(entry.name));
if (!indexStats?.isFile()) continue;
views.push({ slug: entry.name });
}
return views.sort((a, b) => a.slug.localeCompare(b.slug));
}
export async function uploadCustomView(
slug: string,
file: CustomViewUploadFile | undefined,
): Promise<CustomViewSummary> {
const uploadFile = validateCustomViewUpload(file);
ensureDirectory(publicDir.externalDir);
const viewDirectory = resolveCustomViewDirectory(slug);
const indexFile = getPathToCustomView(slug);
try {
await createDirectory(viewDirectory);
} catch (error) {
if (isNodeError(error) && error.code === 'EEXIST') {
throw new CustomViewError(`Name "${slug}" already exists`, 409);
}
throw error;
}
try {
await writeToFile(indexFile, uploadFile.buffer);
return { slug };
} catch (error) {
await deleteDirectory(viewDirectory);
throw error;
}
}
export async function getCustomViewDownloadPath(slug: string): Promise<string> {
const indexFile = getPathToCustomView(slug);
if (!(await fileIsReadable(indexFile))) {
throw new CustomViewError(`Custom view "${slug}" not found`, 404);
}
return indexFile;
}
const demoViewSlug = 'demo';
export async function restoreDemoView(): Promise<CustomViewSummary> {
ensureDirectory(publicDir.externalDir);
const viewDirectory = resolveCustomViewDirectory(demoViewSlug);
const indexFile = getPathToCustomView(demoViewSlug);
await replaceDirectory(viewDirectory);
await writeToFile(indexFile, defaultDemoHtml, { encoding: 'utf-8' });
return { slug: demoViewSlug };
}
export async function deleteCustomView(slug: string): Promise<void> {
await deleteDirectory(resolveCustomViewDirectory(slug));
}
@@ -0,0 +1,16 @@
import { param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { isValidCustomViewSlug } from './customViews.service.js';
export const validateCustomViewSlugParam = [
param('slug')
.isString()
.trim()
.notEmpty()
.customSanitizer((value: string) => value.toLowerCase())
.custom((value: string) => isValidCustomViewSlug(value))
.withMessage('Invalid name. Use lowercase letters, numbers, and dashes only.'),
requestValidationFunction,
];
@@ -0,0 +1,37 @@
import xlsx from 'xlsx';
import { demoDb } from '../../../models/demoProject.js';
import { generateExcelFile } from '../excel.service.js';
describe('generateExcelFile()', () => {
it('sanitises long worksheet names to an Excel-compatible value', () => {
const buffer = generateExcelFile(
{
...demoDb.rundowns.default,
title: 'This is a very long name with many characters and weird things: like [Main]/?*',
},
demoDb.customFields,
);
const workbook = xlsx.read(buffer, { type: 'buffer' });
const worksheetName = workbook.SheetNames[0];
expect(worksheetName).toBeDefined();
expect(worksheetName.length).toBeLessThanOrEqual(31);
expect(worksheetName).not.toMatch(/[:\\/?*3[\]]/);
});
it('falls back to default worksheet name when title is fully invalid', () => {
const buffer = generateExcelFile(
{
...demoDb.rundowns.default,
title: '[]:*?/\\',
},
demoDb.customFields,
);
const workbook = xlsx.read(buffer, { type: 'buffer' });
expect(workbook.SheetNames[0]).toBe('Rundown');
});
});
@@ -21,6 +21,18 @@ import { rundownToTabular } from './excel.utils.js';
// we keep the excel data in memory to allow the flow upload -> preview
let excelData: WorkBook = xlsx.utils.book_new();
const maxWorksheetNameLength = 31;
const invalidWorksheetCharsRegex = /[:\\/?*[\]]/g;
function getValidWorksheetName(title: string): string {
const sanitisedTitle = title.replaceAll(invalidWorksheetCharsRegex, ' ').trim().replace(/\s+/g, ' ');
const withFallback = sanitisedTitle.length > 0 ? sanitisedTitle : 'Rundown';
const truncatedTitle = withFallback.slice(0, maxWorksheetNameLength).trim();
return truncatedTitle.length > 0 ? truncatedTitle : 'Rundown';
}
/**
* Receives and parses an excel file
* The file is deleted after being read
@@ -93,7 +105,8 @@ export function generateExcelFile(rundown: Rundown, customFields: CustomFields):
const workbook = xlsx.utils.book_new();
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
xlsx.utils.book_append_sheet(workbook, worksheet, rundown.title || 'Rundown');
const worksheetName = getValidWorksheetName(rundown.title || 'Rundown');
xlsx.utils.book_append_sheet(workbook, worksheet, worksheetName);
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
+2
View File
@@ -3,6 +3,7 @@ import express from 'express';
import { router as assetsRouter } from './assets/assets.router.js';
import { router as automationsRouter } from './automation/automation.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as customViewsRouter } from './custom-views/customViews.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as excelRouter } from './excel/excel.router.js';
import { router as projectRouter } from './project-data/projectData.router.js';
@@ -18,6 +19,7 @@ export const appRouter = express.Router();
appRouter.use('/automations', automationsRouter);
appRouter.use('/custom-fields', customFieldsRouter);
appRouter.use('/custom-views', customViewsRouter);
appRouter.use('/db', dbRouter);
appRouter.use('/project', projectRouter);
appRouter.use('/rundowns', rundownsRouter);
+91 -25
View File
@@ -42,6 +42,7 @@ import { consoleError, consoleHighlight, consoleSuccess } from './utils/console.
import { generateCrashReport } from './utils/generateCrashReport.js';
import { getNetworkInterfaces } from './utils/network.js';
import { clearUploadfolder } from './utils/upload.js';
import { withTimeout } from './utils/withTimeout.js';
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -64,6 +65,7 @@ const prefix = updateRouterPrefix();
// Create express APP
const app = express();
let isShuttingDown = false;
if (!isProduction) {
// log server timings to requests
app.use(serverTiming());
@@ -85,6 +87,15 @@ app.get(`${prefix}/health`, (_req, res) => {
res.status(200).send('OK');
});
// readiness probe route for orchestrators (e.g. kubernetes)
app.get(`${prefix}/ready`, (_req, res) => {
if (isShuttingDown) {
res.status(503).send('SHUTTING_DOWN');
return;
}
res.status(200).send('READY');
});
// Implement route endpoints
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
@@ -136,6 +147,7 @@ enum OntimeStartOrder {
let step = OntimeStartOrder.InitAssets;
let expressServer: Server | null = null;
let shutdownPromise: Promise<void> | null = null;
const checkStart = (currentState: OntimeStartOrder) => {
if (step !== currentState) {
@@ -250,36 +262,90 @@ export const startIntegrations = async () => {
};
/**
* @description clean shutdown app services
* @param {number} exitCode
* @return {Promise<void>}
* Clean shutdown app services
* - it avoid concurrency issues with deduplication of request to shutdown
* - extracts exit code to modify cleanup behaviour
*/
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
// 2 means dev crash -> do nothing
// 3 means container shutdown -> keep the file
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
await portManager.shutdown();
export async function shutdown(exitCode = 0): Promise<void> {
if (shutdownPromise) {
return shutdownPromise;
}
expressServer?.close();
runtimeService.shutdown();
logger.shutdown();
oscServer.shutdown();
socket.shutdown();
process.exit(exitCode);
shutdownPromise = performShutdown(exitCode);
return shutdownPromise;
}
const closeHttpServer = async (server: Server | null): Promise<void> => {
if (!server) return;
const closePromise = new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
if ((error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING') {
resolve();
return;
}
reject(error);
return;
}
resolve();
});
});
server.closeIdleConnections();
server.closeAllConnections();
await closePromise;
};
const shutdownGlobalTimeout = 10_000; // 10 seconds
const shutdownTimeout = 4_000; // 4 seconds
async function performShutdown(exitCode: number): Promise<void> {
isShuttingDown = true;
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
// if shutdown takes longer than 10 seconds, force exit to avoid hanging processes
const forceExitTimer = setTimeout(() => {
consoleError('Forced shutdown after timeout');
process.exit(exitCode);
}, shutdownGlobalTimeout);
try {
runtimeService.shutdown();
// Block for at most 4 seconds on each shutdown segment
await withTimeout(
flushPendingWrites().catch((_error) => {
/** nothing do to here */
}),
shutdownTimeout,
);
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 2 means dev crash -> do nothing
// 3 means container shutdown -> keep the file
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await withTimeout(restoreService.clear(), shutdownTimeout);
await withTimeout(portManager.shutdown(), shutdownTimeout);
}
await withTimeout(
Promise.all([closeHttpServer(expressServer), socket.shutdown(), oscServer.shutdown()]),
shutdownTimeout,
);
} catch (error) {
logger.error(LogOrigin.Server, `Shutdown error: ${error}`, false);
} finally {
clearTimeout(forceExitTimer);
logger.shutdown();
process.exit(exitCode);
}
}
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', async (error) => {
+305
View File
@@ -0,0 +1,305 @@
export const defaultDemoHtml = `<!doctype html>
<html lang="en">
<head>
<!-- For detailed explanations and examples, refer to the README.md file in this directory -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ontime demo</title>
<style>
body {
margin: 0;
padding: 0;
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
background: #f6f6f6;
color: #222;
}
.container {
display: flex;
flex-direction: row;
gap: 12px;
padding: 10px 12px;
}
.container .column {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.title-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #eaeaea;
}
.logo-title {
display: flex;
align-items: center;
gap: 7px;
}
.logo-title img {
width: 28px;
height: 28px;
object-fit: contain;
}
.card {
padding: 10px 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.card summary.title {
border-bottom: 1px solid #ccc;
padding-bottom: 2px;
margin-bottom: 2px;
}
h1.title,
summary.title {
font-size: 0.95em;
font-weight: 600;
margin: 0;
user-select: none;
}
summary.title {
cursor: pointer;
}
code {
font-size: 0.75em;
font-family: monospace;
background: #f4f4f4;
border-radius: 4px;
padding: 1.5px 3px;
display: inline-block;
white-space: pre;
width: 100%;
}
figure {
margin: 0;
padding: 0;
}
figcaption.description {
color: #555;
font-size: 0.75em;
font-style: italic;
}
</style>
</head>
<body>
<header class="title-card">
<div class="logo-title">
<img
src="https://www.getontime.no/images/icons/ontime-logo.png"
alt="Ontime logo"
onerror="this.style.display = 'none'"
/>
<h1 class="title">Ontime demo</h1>
</div>
<div>
<span>Last message received at</span>
<span id="clock">-</span>
</div>
<nav>
<a href="https://docs.getontime.no/api/data/runtime-data" target="_blank">Help? See docs</a>
</nav>
</header>
<main class="container">
<section class="column">
<details class="card" open>
<summary class="title">Timer</summary>
<figure>
<figcaption class="description">Current timer values</figcaption>
<code id="timer">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Rundown</summary>
<figure>
<figcaption class="description">Progress of the current rundown</figcaption>
<code id="rundown">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Offset</summary>
<figure>
<figcaption class="description">Runtime offset and timings for upcoming targets</figcaption>
<code id="offset">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Event now</summary>
<figure>
<figcaption class="description">Currently loaded event</figcaption>
<code id="eventNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event next</summary>
<figure>
<figcaption class="description">Next scheduled event</figcaption>
<code id="eventNext">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Group now</summary>
<figure>
<figcaption class="description">Currently active group</figcaption>
<code id="groupNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event flag</summary>
<figure>
<figcaption class="description">Currently targeted flag</figcaption>
<code id="eventFlag">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Message</summary>
<figure>
<figcaption class="description">Messaging feature</figcaption>
<code id="message">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Aux timers</summary>
<figure>
<figcaption class="description">Auxiliary Timer 1</figcaption>
<code id="auxtimer1">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 2</figcaption>
<code id="auxtimer2">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 3</figcaption>
<code id="auxtimer3">-</code>
</figure>
</details>
</section>
</main>
<script>
const isSecure = window.location.protocol === 'https:';
const userProvidedSocketUrl = \`\${isSecure ? 'wss' : 'ws'}://\${window.location.host}\${getStageHash()}/ws\`;
connectSocket();
let reconnectTimeout;
const reconnectInterval = 1000;
let reconnectAttempts = 0;
function connectSocket(socketUrl = userProvidedSocketUrl) {
const websocket = new WebSocket(socketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout);
reconnectAttempts = 0;
console.warn('WebSocket connected');
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
reconnectTimeout = setTimeout(() => {
console.warn(\`WebSocket: attempting reconnect \${reconnectAttempts}\`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onmessage = (event) => {
const { tag, payload } = JSON.parse(event.data);
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
}
let localData = {};
function handleOntimePayload(payload) {
localData = { ...localData, ...payload };
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
if ('message' in payload) updateDOM('message', formatObject(payload.message));
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
}
function updateDOM(field, payload) {
const domElement = document.getElementById(field);
if (domElement) {
domElement.innerText = payload;
}
}
const millisToSeconds = 1000;
const millisToMinutes = 1000 * 60;
const millisToHours = 1000 * 60 * 60;
function formatTimer(number) {
if (number == null) {
return '--:--:--';
}
const millis = Math.abs(number);
const isNegative = number < 0;
return \`\${isNegative ? '-' : ''}\${leftPad(millis / millisToHours)}:\${leftPad(
(millis % millisToHours) / millisToMinutes,
)}:\${leftPad((millis % millisToMinutes) / millisToSeconds)}\`;
function leftPad(val) {
return Math.floor(val).toString().padStart(2, '0');
}
}
function formatObject(data) {
return JSON.stringify(data, null, 2);
}
function getStageHash() {
const href = window.location.href;
if (!href.includes('getontime.no')) {
return '';
}
const hash = href.split('/');
const stageHash = hash.at(3);
return stageHash ? \`/\${stageHash}\` : '';
}
</script>
</body>
</html>`;
+406
View File
@@ -8,3 +8,409 @@ http://<ip-address>:<port>/external/<folder-name>
```
https://docs.getontime.no/features/custom-views/
## Demo
This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime.
Here, we subscribe to the websocket and display all the data received in a grid.
Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment.
### Getting the data
To subscribe to the websocket you will need:
- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001`
- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on.
#### Example
- Ontime URL: `https://cloud.getontime.no/stage-123`
- Ontime token: `token-from-share`
```js
// use wss since we are connecting to an https address
const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`;
/**
* Connects to the websocket server
* NOTE: this demo does not handle reconnections or errors
* @param {string} socketUrl
*/
const connectSocket = (socketUrl) => {
const websocket = new WebSocket(socketUrl);
websocket.onmessage = (event) => {
// all objects from ontime are structured with tag and payload
const { tag, payload } = JSON.parse(event.data);
// runtime-data is sent on connect, with the full state
// runtime-patch is sent on every change to the state
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
};
```
### Runtime data
`runtime-data` contains a patch of all the data in the server
you would need to create a function that parses the patch and extract the data you need
In our case, we simply map the data to a DOM element with the same ID as the field name.
[See the docs](https://docs.getontime.no/api/data/runtime-data/).
#### Example of handling the payload
```js
const handleOntimePayload = (payload) => {
// 1. apply the patch into your local copy of the data
localData = { ...localData, ...payload };
// 2. update the UI with the new data
// ... timer data
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
// ... rundown data
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
// ... runtime
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
// ... relevant entries
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
// ... messages service
if ('message' in payload) updateDOM('message', formatObject(payload.message));
// ... extra timers
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
};
```
#### Payload example
See below what the payload looks like.
Note: all timer values are in milliseconds.
```jsonc
{
/** Current server clock value */
"clock": 37816011,
/**
* Gathers the current running timer state
*/
"timer": {
/** Additional time added to the running timer, can be negative */
"addedTime": 0,
/** Current running timer countdown */
"current": 3574976,
/** Total duration of the running event */
"duration": 3600000,
/** Time elapsed since the timer started */
"elapsed": 25024,
/** Timestamp of the expected finish time */
"expectedFinish": 41391285,
/** Current phase of the running event */
"phase": "default",
/** Timer's playback state */
"playback": "play",
/** Secondary timer, used to count to an event start in roll mode */
"secondaryTimer": null,
/** Timestamp when the timer started */
"startedAt": 37791285,
},
/**
* Offset represents our current position in relation to the planned time
* a positive value means that we have added extra time to the expected end
* aka behind schedule
*/
"offset": {
/** Current absolute offset: accounts for planned times */
"absolute": 40394840,
/** Current relative offset: only counts for generated offset since start */
"relative": -35997119,
/** Currently selected offset mode */
"mode": "absolute",
/** Timestamp of the expected start of the next flag */
"expectedFlagStart": 80594840,
/** Timestamp of the expected end of the current group */
"expectedGroupEnd": 83594840,
/** Timestamp of the expected end of the loaded rundown */
"expectedRundownEnd": 90794840,
},
/** Data object describes rundown schedule and the current progress */
"rundown": {
/** Index of the currently selected event */
"selectedEventIndex": 1,
/** Total number of events */
"numEvents": 7,
/** Timestamp of the rundown's planned start time */
"plannedStart": 0,
/** Timestamp of the rundown's planned end time */
"plannedEnd": 50400000,
/** Timestamp of when the rundown was actually started */
"actualStart": 76391959,
},
/** Data of currently loaded event */
"eventNow": {
/** Unique identifier for the event */
"id": "9bf60f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Pre-show Countdown",
/** Timestamp of the planned start time */
"timeStart": 36000000,
/** Timestamp of the planned end time */
"timeEnd": 39600000,
/** Planned event duration */
"duration": 3600000,
/** Strategy for time management */
"timeStrategy": "lock-end",
/** Whether the event is linked to the start of the previous */
"linkStart": false,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Music plays, holding slide on screens",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 600000,
/** Danger time */
"timeDanger": 300000,
/** Custom fields for the event */
"custom": { "Custom_Field": "Put additional info here" },
/** Triggers associated with the event */
"triggers": [],
},
/** Upcoming event data */
"eventNext": {
/** Unique identifier for the event */
"id": "c2697f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Welcome",
/** Timestamp of the planned start time */
"timeStart": 39600000,
/** Timestamp of the planned end time */
"timeEnd": 40200000,
/** Planned event duration */
"duration": 600000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Emma Thompson",
/** Colour code for the event */
"colour": "#FFCC78",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Data of currently targetted flag event */
"eventFlag": {
/** Unique identifier for the event */
"id": "fa593e",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": true,
/** Title of the event */
"title": "Session 1",
/** Timestamp of the planned start time */
"timeStart": 40200000,
/** Timestamp of the planned end time */
"timeEnd": 43200000,
/** Planned event duration */
"duration": 3000000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Liam Carter, Sophia Patel + PowerPoint",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.2",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Current group data */
"groupNow": {
/** Unique identifier for the group */
"id": "7eaf99",
/** Entry type */
"type": "group",
/** Title of the group */
"title": "Morning Sessions",
/** Note associated with the group */
"note": "",
/** ID of entries nested in the group */
"entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"],
/** Optional, user defined target duration */
"targetDuration": null,
/** Colour code for the group */
"colour": "#339E4E",
/** Custom fields for the group */
"custom": {},
/** Revision number for the entry */
"revision": 0,
/** Timestamp of the first event's planned start time */
"timeStart": 36000000,
/** Timestamp of the last event's planned end time */
"timeEnd": 43200000,
/** Accumulated events duration */
"duration": 7200000,
/** Whether the first event has its start time linked */
"isFirstLinked": false,
},
/** Message object with data */
"message": {
/** Timer view message data */
"timer": {
/** Text associated with the timer view */
"text": "",
/** Whether the message is visible */
"visible": false,
/** Whether the timer view is blinking */
"blink": false,
/** Whether the timer view is blacked out */
"blackout": false,
/** Secondary source for the view */
"secondarySource": null,
},
/** Secondary message text */
"secondary": "",
},
/** Auxiliary timer 1 */
"auxtimer1": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 2 */
"auxtimer2": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 3 */
"auxtimer3": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
}
```
## Links
- [Ontime Documentation](https://docs.getontime.no)
- [GitHub Repository](https://github.com/getontime/ontime)
- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/)
-405
View File
@@ -1,405 +0,0 @@
## Demo
This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime.
Here, we subscribe to the websocket and display all the data received in a grid.
Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment.
### Getting the data
To subscribe to the websocket you will need:
- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001`
- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on.
#### Example
- Ontime URL: `https://cloud.getontime.no/stage-123`
- Ontime token: `token-from-share`
```js
// use wss since we are connecting to an https address
const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`;
/**
* Connects to the websocket server
* NOTE: this demo does not handle reconnections or errors
* @param {string} socketUrl
*/
const connectSocket = (socketUrl) => {
const websocket = new WebSocket(socketUrl);
websocket.onmessage = (event) => {
// all objects from ontime are structured with tag and payload
const { tag, payload } = JSON.parse(event.data);
// runtime-data is sent on connect, with the full state
// runtime-patch is sent on every change to the state
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
};
```
### Runtime data
`runtime-data` contains a patch of all the data in the server
you would need to create a function that parses the patch and extract the data you need
In our case, we simply map the data to a DOM element with the same ID as the field name.
[See the docs](https://docs.getontime.no/api/data/runtime-data/).
#### Example of handling the payload
```js
const handleOntimePayload = (payload) => {
// 1. apply the patch into your local copy of the data
localData = { ...localData, ...payload };
// 2. update the UI with the new data
// ... timer data
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
// ... rundown data
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
// ... runtime
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
// ... relevant entries
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
// ... messages service
if ('message' in payload) updateDOM('message', formatObject(payload.message));
// ... extra timers
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
};
```
#### Payload example
See below what the payload looks like.
Note: all timer values are in milliseconds.
```jsonc
{
/** Current server clock value */
"clock": 37816011,
/**
* Gathers the current running timer state
*/
"timer": {
/** Additional time added to the running timer, can be negative */
"addedTime": 0,
/** Current running timer countdown */
"current": 3574976,
/** Total duration of the running event */
"duration": 3600000,
/** Time elapsed since the timer started */
"elapsed": 25024,
/** Timestamp of the expected finish time */
"expectedFinish": 41391285,
/** Current phase of the running event */
"phase": "default",
/** Timer's playback state */
"playback": "play",
/** Secondary timer, used to count to an event start in roll mode */
"secondaryTimer": null,
/** Timestamp when the timer started */
"startedAt": 37791285,
},
/**
* Offset represents our current position in relation to the planned time
* a positive value means that we have added extra time to the expected end
* aka behind schedule
*/
"offset": {
/** Current absolute offset: accounts for planned times */
"absolute": 40394840,
/** Current relative offset: only counts for generated offset since start */
"relative": -35997119,
/** Currently selected offset mode */
"mode": "absolute",
/** Timestamp of the expected start of the next flag */
"expectedFlagStart": 80594840,
/** Timestamp of the expected end of the current group */
"expectedGroupEnd": 83594840,
/** Timestamp of the expected end of the loaded rundown */
"expectedRundownEnd": 90794840,
},
/** Data object describes rundown schedule and the current progress */
"rundown": {
/** Index of the currently selected event */
"selectedEventIndex": 1,
/** Total number of events */
"numEvents": 7,
/** Timestamp of the rundown's planned start time */
"plannedStart": 0,
/** Timestamp of the rundown's planned end time */
"plannedEnd": 50400000,
/** Timestamp of when the rundown was actually started */
"actualStart": 76391959,
},
/** Data of currently loaded event */
"eventNow": {
/** Unique identifier for the event */
"id": "9bf60f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Pre-show Countdown",
/** Timestamp of the planned start time */
"timeStart": 36000000,
/** Timestamp of the planned end time */
"timeEnd": 39600000,
/** Planned event duration */
"duration": 3600000,
/** Strategy for time management */
"timeStrategy": "lock-end",
/** Whether the event is linked to the start of the previous */
"linkStart": false,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Music plays, holding slide on screens",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 600000,
/** Danger time */
"timeDanger": 300000,
/** Custom fields for the event */
"custom": { "Custom_Field": "Put additional info here" },
/** Triggers associated with the event */
"triggers": [],
},
/** Upcoming event data */
"eventNext": {
/** Unique identifier for the event */
"id": "c2697f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Welcome",
/** Timestamp of the planned start time */
"timeStart": 39600000,
/** Timestamp of the planned end time */
"timeEnd": 40200000,
/** Planned event duration */
"duration": 600000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Emma Thompson",
/** Colour code for the event */
"colour": "#FFCC78",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Data of currently targetted flag event */
"eventFlag": {
/** Unique identifier for the event */
"id": "fa593e",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": true,
/** Title of the event */
"title": "Session 1",
/** Timestamp of the planned start time */
"timeStart": 40200000,
/** Timestamp of the planned end time */
"timeEnd": 43200000,
/** Planned event duration */
"duration": 3000000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Liam Carter, Sophia Patel + PowerPoint",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.2",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Current group data */
"groupNow": {
/** Unique identifier for the group */
"id": "7eaf99",
/** Entry type */
"type": "group",
/** Title of the group */
"title": "Morning Sessions",
/** Note associated with the group */
"note": "",
/** ID of entries nested in the group */
"entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"],
/** Optional, user defined target duration */
"targetDuration": null,
/** Colour code for the group */
"colour": "#339E4E",
/** Custom fields for the group */
"custom": {},
/** Revision number for the entry */
"revision": 0,
/** Timestamp of the first event's planned start time */
"timeStart": 36000000,
/** Timestamp of the last event's planned end time */
"timeEnd": 43200000,
/** Accumulated events duration */
"duration": 7200000,
/** Whether the first event has its start time linked */
"isFirstLinked": false,
},
/** Message object with data */
"message": {
/** Timer view message data */
"timer": {
/** Text associated with the timer view */
"text": "",
/** Whether the message is visible */
"visible": false,
/** Whether the timer view is blinking */
"blink": false,
/** Whether the timer view is blacked out */
"blackout": false,
/** Secondary source for the view */
"secondarySource": null,
},
/** Secondary message text */
"secondary": "",
},
/** Auxiliary timer 1 */
"auxtimer1": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 2 */
"auxtimer2": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 3 */
"auxtimer3": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
}
```
## Links
- [Ontime Documentation](https://docs.getontime.no)
- [GitHub Repository](https://github.com/getontime/ontime)
- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/)
-157
View File
@@ -1,157 +0,0 @@
/*eslint-env browser*/
/**
* This is a very minimal example for a websocket client
* You could use this as a starting point to creating your own interfaces
*/
// Data that the user needs to provide depending on the Ontime URL
const isSecure = window.location.protocol === 'https:';
const userProvidedSocketUrl = `${isSecure ? 'wss' : 'ws'}://${window.location.host}${getStageHash()}/ws`;
connectSocket();
let reconnectTimeout;
const reconnectInterval = 1000;
let reconnectAttempts = 0;
/**
* Connects to the websocket server
* @param {string} socketUrl
*/
function connectSocket(socketUrl = userProvidedSocketUrl) {
const websocket = new WebSocket(socketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout);
reconnectAttempts = 0;
console.warn('WebSocket connected');
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
reconnectTimeout = setTimeout(() => {
console.warn(`WebSocket: attempting reconnect ${reconnectAttempts}`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onmessage = (event) => {
// all objects from ontime are structured with tag and payload
const { tag, payload } = JSON.parse(event.data);
/**
* runtime-data is sent
* - on connect with the full state
* - and then on every update with a patch
*/
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
}
let localData = {};
/**
* Handles the ontime payload updates
* @param {object} payload - The payload object containing the updates
*/
function handleOntimePayload(payload) {
// 1. apply the patch into your local copy of the data
localData = { ...localData, ...payload };
// 2. update the UI with the new data
// ... timer data
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
// ... rundown data
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
// ... runtime
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
// ... relevant entries
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
// ... messages service
if ('message' in payload) updateDOM('message', formatObject(payload.message));
// ... extra timers
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
}
/**
* Updates the DOM with a given payload
* @param {string} field - The runtime data field
* @param {object} payload - The patch object for the field
*/
function updateDOM(field, payload) {
const domElement = document.getElementById(field);
if (domElement) {
domElement.innerText = payload;
}
}
// Time constants used for calculating times
const millisToSeconds = 1000;
const millisToMinutes = 1000 * 60;
const millisToHours = 1000 * 60 * 60;
/**
* Formats a timer value into a human-readable string
* @param {number} number - The timer value in milliseconds
* @returns {string} The formatted timer string
*/
function formatTimer(number) {
if (number == null) {
return '--:--:--';
}
const millis = Math.abs(number);
const isNegative = number < 0;
return `${isNegative ? '-' : ''}${leftPad(millis / millisToHours)}:${leftPad(
(millis % millisToHours) / millisToMinutes,
)}:${leftPad((millis % millisToMinutes) / millisToSeconds)}`;
/**
* Pads a number with leading zeros
* @param {number} number - The number to pad
* @returns {string} The padded number string
*/
function leftPad(val) {
return Math.floor(val).toString().padStart(2, '0');
}
}
/**
* Stringifies an object into a pretty string
* @param {object} data - The data object to format
* @returns {string} The formatted data string
*/
function formatObject(data) {
return JSON.stringify(data, null, 2);
}
/**
* Utility to handle a demo deployed in an ontime stage
* You can likely ignore this in your app
*
* an url looks like
* https://cloud.getontime.no/stage-hash/external/demo/ -> /stage-hash
* @returns {string} - The stage hash if the app is running in an ontime stage
*/
function getStageHash() {
const href = window.location.href;
if (!href.includes('getontime.no')) {
return '';
}
const hash = href.split('/');
const stageHash = hash.at(3);
return stageHash ? `/${stageHash}` : '';
}
-116
View File
@@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<!-- For detailed explanations and examples, refer to the README.md file in this directory -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ontime demo</title>
<link href="./styles.css" rel="stylesheet" />
</head>
<body>
<header class="title-card">
<div class="logo-title">
<img
src="https://www.getontime.no/images/icons/ontime-logo.png"
alt="Ontime logo"
onerror="this.style.display = 'none'"
/>
<h1 class="title">Ontime demo</h1>
</div>
<div>
<span>Last message received at</span>
<span id="clock">-</span>
</div>
<nav>
<a href="https://docs.getontime.no/api/data/runtime-data" target="_blank">Help? See docs</a>
<div>See <a href="README.md">README.md</a> details.</div>
</nav>
</header>
<main class="container">
<section class="column">
<details class="card" open>
<summary class="title">Timer</summary>
<figure>
<figcaption class="description">Current timer values</figcaption>
<code id="timer">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Rundown</summary>
<figure>
<figcaption class="description">Progress of the current rundown</figcaption>
<code id="rundown">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Offset</summary>
<figure>
<figcaption class="description">Runtime offset and timings for upcoming targets</figcaption>
<code id="offset">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Event now</summary>
<figure>
<figcaption class="description">Currently loaded event</figcaption>
<code id="eventNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event next</summary>
<figure>
<figcaption class="description">Next scheduled event</figcaption>
<code id="eventNext">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Group now</summary>
<figure>
<figcaption class="description">Currently active group</figcaption>
<code id="groupNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event flag</summary>
<figure>
<figcaption class="description">Currently targeted flag</figcaption>
<code id="eventFlag">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Message</summary>
<figure>
<figcaption class="description">Messaging feature</figcaption>
<code id="message">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Aux timers</summary>
<figure>
<figcaption class="description">Auxiliary Timer 1</figcaption>
<code id="auxtimer1">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 2</figcaption>
<code id="auxtimer2">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 3</figcaption>
<code id="auxtimer3">-</code>
</figure>
</details>
</section>
</main>
<script src="./app.js" type="text/javascript"></script>
</body>
</html>
-91
View File
@@ -1,91 +0,0 @@
body {
margin: 0;
padding: 0;
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
background: #f6f6f6;
color: #222;
}
.container {
display: flex;
flex-direction: row;
gap: 12px;
padding: 10px 12px;
}
.container .column {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.title-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #eaeaea;
}
.logo-title {
display: flex;
align-items: center;
gap: 7px;
}
.logo-title img {
width: 28px;
height: 28px;
object-fit: contain;
}
.card {
padding: 10px 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.card summary.title {
border-bottom: 1px solid #ccc;
padding-bottom: 2px;
margin-bottom: 2px;
}
h1.title,
summary.title {
font-size: 0.95em;
font-weight: 600;
margin: 0;
user-select: none;
}
summary.title {
cursor: pointer;
}
code {
font-size: 0.75em;
font-family: monospace;
background: #f4f4f4;
border-radius: 4px;
padding: 1.5px 3px;
display: inline-block;
white-space: pre;
width: 100%;
}
figure {
margin: 0;
padding: 0;
}
figcaption.description {
color: #555;
font-size: 0.75em;
font-style: italic;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { copyFileSync, existsSync, writeFileSync } from 'fs';
import { defaultTranslation } from '../user/translations/bundledTranslations.js';
import { defaultTranslation } from '../bundle/bundledTranslations.js';
import { ensureDirectory } from '../utils/fileManagement.js';
import { publicDir, publicFiles, srcFiles } from './index.js';
+76 -2
View File
@@ -1,5 +1,5 @@
import { PathLike, constants, existsSync, mkdirSync } from 'fs';
import { copyFile, readdir, unlink } from 'fs/promises';
import { type Dirent, PathLike, type Stats, constants, existsSync, mkdirSync } from 'fs';
import { access, copyFile, mkdir, readdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { basename, join, parse } from 'path';
import { consoleError } from './console.js';
@@ -152,3 +152,77 @@ export const deleteFile = async (filePath: string) => {
console.error('Could not delete file:', error);
});
};
export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
/**
* Returns file stats, or null if the path does not exist.
* Re-throws any error that is not ENOENT.
*/
export async function statIfExists(filePath: string): Promise<Stats | null> {
try {
return await stat(filePath);
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return null;
}
throw error;
}
}
/**
* Recursively deletes a directory and all its contents.
* No-op if the directory does not exist.
*/
export async function deleteDirectory(directoryPath: string): Promise<void> {
await rm(directoryPath, { recursive: true, force: true });
}
/**
* Removes a directory if it exists and creates it fresh.
*/
export async function replaceDirectory(directoryPath: string): Promise<void> {
await rm(directoryPath, { recursive: true, force: true });
await mkdir(directoryPath, { recursive: true });
}
/**
* Creates a directory. Throws if it already exists.
*/
export async function createDirectory(directoryPath: string): Promise<void> {
await mkdir(directoryPath);
}
/**
* Returns directory entries with file type information.
*/
export async function readDirectoryEntries(directoryPath: string): Promise<Dirent[]> {
return readdir(directoryPath, { withFileTypes: true });
}
/**
* Writes content to a file, creating it if it does not exist.
*/
export async function writeToFile(
filePath: string,
content: string | Buffer,
options?: { encoding?: BufferEncoding },
): Promise<void> {
await writeFile(filePath, content, options);
}
/**
* Returns true if the file exists and is readable, false if it does not exist.
* Re-throws any error that is not ENOENT.
*/
export async function fileIsReadable(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.R_OK);
return true;
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') return false;
throw error;
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Resolves or rejects with the provided promise, but fails if it does not settle within `timeoutMs`.
*/
export const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
let timer: NodeJS.Timeout | null = null;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error('Operation timed out')), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) {
clearTimeout(timer);
}
});
};
+2 -2
View File
@@ -7,7 +7,7 @@ const fileToUpload = 'e2e/tests/fixtures/e2e-test-db.json';
const fileToDownload = 'e2e/tests/fixtures/tmp/';
test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.goto('/editor');
// Try to close welcome modal if it appears (times out silently if not present)
try {
@@ -47,7 +47,7 @@ test('project file upload', async ({ page }) => {
//TODO: this works when testing locally, but not in github actions
test.fixme('project file download', async ({ page }) => {
await page.goto('http://localhost:4001/editor/?settings=project__manage');
await page.goto('/editor/?settings=project__manage');
await page
.getByRole('row', { name: /.*currently loaded/i })
+9 -9
View File
@@ -2,7 +2,7 @@ import { type Page, expect, test } from '@playwright/test';
test.describe('test view navigation feature', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:4001/');
await page.goto('/');
await expect(page.locator('data-testid=timer-view')).toBeVisible();
});
@@ -10,51 +10,51 @@ test.describe('test view navigation feature', () => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Timeline' }).click();
page.locator('data-testid=timeline-view');
await expect(page).toHaveURL('http://localhost:4001/timeline');
await expect(page).toHaveURL('/timeline');
});
test('Backstage', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Backstage' }).click();
page.locator('data-testid=backstage-view');
await expect(page).toHaveURL('http://localhost:4001/backstage');
await expect(page).toHaveURL('/backstage');
});
test('Studio Clock', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Studio Clock' }).click();
page.locator('data-testid=studio-view');
await expect(page).toHaveURL('http://localhost:4001/studio');
await expect(page).toHaveURL('/studio');
});
test('Countdown', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Countdown' }).click();
page.locator('data-testid=countdown-view');
await expect(page).toHaveURL('http://localhost:4001/countdown');
await expect(page).toHaveURL('/countdown');
});
test('Project Info', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Project Info' }).click();
page.locator('data-testid=project-view');
await expect(page).toHaveURL('http://localhost:4001/info');
await expect(page).toHaveURL('/info');
});
test('Timer', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Timer', exact: true }).click();
page.locator('data-testid=timer-view');
await expect(page).toHaveURL('http://localhost:4001/timer');
await expect(page).toHaveURL('/timer');
});
test('not-found', async ({ page }) => {
await page.goto('http://localhost:4001/not-found');
await page.goto('/not-found');
await expect(page).toHaveTitle(/ontime/);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
await page.goto('http://localhost:4001/preset/not-found');
await page.goto('/preset/not-found');
await expect(page).toHaveTitle(/ontime/);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
@@ -4,14 +4,14 @@ test('message control sends messages to screens', async ({ context }) => {
const editorPage = await context.newPage();
const featurePage = await context.newPage();
await editorPage.goto('http://localhost:4001/messagecontrol');
await editorPage.goto('/messagecontrol');
// stage timer message
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').click();
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').fill('testing stage');
await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
await featurePage.goto('http://localhost:4001/timer');
await featurePage.goto('/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
await expect(featurePage.getByText('testing stage')).toBeVisible();
+4 -4
View File
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
test('delays add time to events', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.goto('/editor');
// delete all events and add a new one
await page.getByRole('button', { name: 'Edit' }).click();
@@ -50,7 +50,7 @@ test('delays add time to events', async ({ page }) => {
});
test('delays are show correctly', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.goto('/editor');
// add a test event
await page.getByRole('button', { name: 'Edit' }).click();
@@ -81,10 +81,10 @@ test('delays are show correctly', async ({ page }) => {
await page.getByText('New start 00:11').click();
// delay is shown in the cuesheet
await page.goto('http://localhost:4001/cuesheet');
await page.goto('/cuesheet');
await page.getByRole('cell', { name: 'Delayed by 1 min' }).click();
// delay is shown in the backstage view
await page.goto('http://localhost:4001/backstage');
await page.goto('/backstage');
await page.getByText('00:11→00:21').click();
});

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