mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24e325726c |
@@ -44,16 +44,6 @@ 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:
|
||||
@@ -83,16 +73,6 @@ 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:
|
||||
@@ -120,17 +100,6 @@ 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:
|
||||
|
||||
@@ -33,9 +33,6 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run code quality
|
||||
- name: Run formatter
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ dist/
|
||||
# bundled assets
|
||||
translations.json
|
||||
override.css
|
||||
**/external/demo/index.html
|
||||
|
||||
# working stuff
|
||||
**/TODO.md
|
||||
|
||||
@@ -99,13 +99,6 @@ 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.6.0",
|
||||
"version": "4.5.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.6.0",
|
||||
"version": "4.5.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,22 +57,20 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "5.1.1",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"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",
|
||||
"vite": "6.3.1",
|
||||
"vite-plugin-compression2": "2.2.0",
|
||||
"vite-plugin-svgr": "4.3.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ 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'];
|
||||
@@ -15,7 +13,6 @@ 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'];
|
||||
@@ -24,7 +21,9 @@ 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}`;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -31,8 +31,8 @@ export async function postShowWelcomeDialog(show: boolean) {
|
||||
/**
|
||||
* HTTP request to retrieve server port
|
||||
*/
|
||||
export async function getServerPort(options?: RequestOptions): Promise<PortInfo> {
|
||||
const res = await axios.get(`${settingsPath}/serverport`, { signal: options?.signal });
|
||||
export async function getServerPort(): Promise<PortInfo> {
|
||||
const res = await axios.get(`${settingsPath}/serverport`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
@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%;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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,11 +125,6 @@
|
||||
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} keepMounted />
|
||||
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
|
||||
<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} keepMounted />
|
||||
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
|
||||
</BaseSelect.Positioner>
|
||||
</BaseSelect.Portal>
|
||||
</BaseSelect.Root>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
.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,14 +1,11 @@
|
||||
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, variant = 'default', children }: PropsWithChildren<TagProps>) {
|
||||
return <span className={cx([style.tag, style[variant], className])}>{children}</span>;
|
||||
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
|
||||
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -139,9 +139,7 @@ export const setEventPlayback = {
|
||||
pause: () => sendSocket('pause', undefined),
|
||||
};
|
||||
|
||||
export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||
...state.timer,
|
||||
}));
|
||||
export const useTimer = createSelector((state: RuntimeStore) => state.timer);
|
||||
|
||||
export const useClock = createSelector((state: RuntimeStore) => state.clock);
|
||||
|
||||
@@ -202,20 +200,20 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||
actualGroupStart: state.rundown.actualGroupStart,
|
||||
// we can force these numbers to 0 for this use case to avoid null checks
|
||||
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||
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,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
// we can force these numbers to 0 for this use case to avoid null checks
|
||||
actualStart: state.rundown.actualStart ?? 0,
|
||||
plannedStart: state.rundown.plannedStart ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
playback: state.timer.playback,
|
||||
phase: state.timer.phase,
|
||||
}));
|
||||
|
||||
/* ======================= View specific subscriptions ======================= */
|
||||
|
||||
@@ -13,7 +13,6 @@ import { isProduction, websocketUrl } from '../../externals';
|
||||
import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CSS_OVERRIDE,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
REPORT,
|
||||
@@ -200,9 +199,6 @@ 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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
declare module 'virtual:prismjs' {
|
||||
const prism: typeof import('prismjs');
|
||||
export default prism;
|
||||
}
|
||||
@@ -140,21 +140,11 @@ $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,28 +85,18 @@ export function ListItem({ children }: { children: ReactNode }) {
|
||||
return <li className={style.listItem}>{children}</li>;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
descriptionTone = 'default',
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
error?: string;
|
||||
descriptionTone?: 'default' | 'warning';
|
||||
}) {
|
||||
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
|
||||
return (
|
||||
<div className={style.fieldTitle}>
|
||||
<div className={style.fieldHeading}>{title}</div>
|
||||
{title}
|
||||
{error && <Error>{error}</Error>}
|
||||
{!error && description && <Description tone={descriptionTone}>{description}</Description>}
|
||||
{!error && description && <Description>{description}</Description>}
|
||||
</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 Description({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldDescription}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Highlight({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -282,7 +282,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
<Button type='submit' onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,6 @@ 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 (
|
||||
<>
|
||||
@@ -26,15 +24,13 @@ 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} enabledAutomations={automationState} />
|
||||
<AutomationsList automations={data.automations} />
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</>
|
||||
|
||||
+7
-33
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -19,16 +18,12 @@ interface AutomationSettingsProps {
|
||||
enabledAutomations: boolean;
|
||||
enabledOscIn: boolean;
|
||||
oscPortIn: number;
|
||||
automationState?: boolean;
|
||||
oscInputState?: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationSettingsForm({
|
||||
enabledAutomations,
|
||||
enabledOscIn,
|
||||
oscPortIn,
|
||||
automationState,
|
||||
oscInputState,
|
||||
}: AutomationSettingsProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -61,8 +56,6 @@ export default function AutomationSettingsForm({
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
const automationsEnabled = watch('enabledAutomations');
|
||||
const oscInputEnabled = watch('enabledOscIn');
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
@@ -109,52 +102,33 @@ export default function AutomationSettingsForm({
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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'}
|
||||
title='Enable automations'
|
||||
description='Allow Ontime to send messages on lifecycle triggers'
|
||||
error={errors.enabledAutomations?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={automationsEnabled}
|
||||
checked={watch('enabledAutomations')}
|
||||
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={
|
||||
<>
|
||||
<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'}
|
||||
title='OSC input'
|
||||
description='Allow control of Ontime through OSC'
|
||||
error={errors.enabledOscIn?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={oscInputEnabled}
|
||||
checked={watch('enabledOscIn')}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -21,11 +20,10 @@ const automationPlaceholder: AutomationDTO = {
|
||||
|
||||
interface AutomationsListProps {
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationsList(props: AutomationsListProps) {
|
||||
const { automations, enabledAutomations } = props;
|
||||
const { automations } = props;
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -58,13 +56,6 @@ 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,11 +66,6 @@ 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,7 +5,6 @@ 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';
|
||||
@@ -15,11 +14,10 @@ import TriggersListItem from './TriggersListItem';
|
||||
interface TriggersListProps {
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
}
|
||||
|
||||
export default function TriggersList(props: TriggersListProps) {
|
||||
const { triggers, automations, enabledAutomations } = props;
|
||||
const { triggers, automations } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -54,11 +52,6 @@ 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.
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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/<name>/</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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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,11 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { PortInfo } from 'ontime-types';
|
||||
import { useCallback, useEffect, useState } 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 useServerPort from '../../../../common/hooks-query/useServerPort';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -15,22 +16,29 @@ 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(() => {
|
||||
reset({ serverPort: data.port });
|
||||
}, [data.pendingRestart, data.port, reset]);
|
||||
getServerPort()
|
||||
.then(setPort)
|
||||
.catch(() => setError('root', { message: 'Failed to load server port' }));
|
||||
}, [reset, setError, setPort]);
|
||||
|
||||
const onSubmit = async (formData: ServerPortForm) => {
|
||||
if (formData.serverPort < 1024 || formData.serverPort > 65535) {
|
||||
@@ -38,27 +46,21 @@ export default function ServerPortSettings() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
clearErrors('root');
|
||||
await mutateAsync(formData.serverPort);
|
||||
await postServerPort(formData.serverPort);
|
||||
setPort(await getServerPort());
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
clearErrors('root');
|
||||
const result = await refetch();
|
||||
|
||||
if (result.isError) {
|
||||
try {
|
||||
setPort(await getServerPort());
|
||||
} catch (error) {
|
||||
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'
|
||||
@@ -70,6 +72,7 @@ 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>
|
||||
@@ -85,13 +88,9 @@ export default function ServerPortSettings() {
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||
{errors.root && <Panel.Error>{errors.root.message}</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,7 +2,6 @@ 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';
|
||||
@@ -11,9 +10,8 @@ import ViewSettings from './ViewSettings';
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -27,9 +25,6 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={viewRef}>
|
||||
<ViewSettings />
|
||||
</div>
|
||||
<div ref={customViewsRef}>
|
||||
<CustomViews />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -56,8 +55,6 @@ export default function ViewSettings() {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const overrideStylesEnabled = watch('overrideStyles');
|
||||
|
||||
if (!control) {
|
||||
return null;
|
||||
}
|
||||
@@ -94,22 +91,12 @@ export default function ViewSettings() {
|
||||
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={codeEditorHandler.close} />
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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'}
|
||||
title='Override CSS styles'
|
||||
description='Enables overriding view styles with custom stylesheet'
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={overrideStylesEnabled}
|
||||
checked={watch('overrideStyles')}
|
||||
onCheckedChange={(value: boolean) => setValue('overrideStyles', value, { shouldDirty: true })}
|
||||
/>
|
||||
<Button onClick={codeEditorHandler.open} disabled={isSubmitting}>
|
||||
|
||||
-1
@@ -1,5 +1,4 @@
|
||||
.wrapper {
|
||||
min-height: 500px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
+45
-12
@@ -1,38 +1,71 @@
|
||||
import { memo } from 'react';
|
||||
import Prism from 'prismjs/components/prism-core';
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle, useState } 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;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
initialValue: string;
|
||||
isDirty: boolean;
|
||||
setIsDirty: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export default memo(CodeEditor);
|
||||
function CodeEditor({ language, onChange, value }: CodeEditorProps) {
|
||||
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
|
||||
const { language, initialValue, isDirty, setIsDirty } = props;
|
||||
|
||||
const [code, setCode] = useState(initialValue);
|
||||
|
||||
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={value}
|
||||
value={code}
|
||||
padding={15}
|
||||
onValueChange={onChange}
|
||||
onValueChange={handleChange}
|
||||
highlight={highlight}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
minHeight: 500,
|
||||
background: 'transparent',
|
||||
background: '#2d2d2d', // Background of tomorrow theme
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
CodeEditor.displayName = 'StyleEditor';
|
||||
|
||||
export default memo(CodeEditor);
|
||||
|
||||
-9
@@ -13,12 +13,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
+19
-39
@@ -1,4 +1,4 @@
|
||||
import { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { Suspense, lazy, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
@@ -15,21 +15,24 @@ interface CodeEditorModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CSSRef {
|
||||
getCss: () => string;
|
||||
}
|
||||
|
||||
export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProps) {
|
||||
const [savedCss, setSavedCss] = useState('');
|
||||
const [draftCss, setDraftCss] = useState('');
|
||||
const [isLoadingCss, setIsLoadingCss] = useState(false);
|
||||
const [css, setCSS] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isDirty = savedCss.trim() !== draftCss.trim();
|
||||
const cssRef = useRef<CSSRef>(null);
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
setResetLoading(true);
|
||||
const defaultCss = await restoreCSSContents();
|
||||
setDraftCss(defaultCss);
|
||||
setCSS(defaultCss);
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
@@ -40,8 +43,11 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaveLoading(true);
|
||||
await postCSSContents(draftCss);
|
||||
setSavedCss(draftCss);
|
||||
if (cssRef.current) {
|
||||
await postCSSContents(cssRef.current.getCss());
|
||||
setCSS(cssRef.current.getCss());
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
@@ -49,43 +55,22 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => setDraftCss('');
|
||||
const clear = () => setCSS('');
|
||||
|
||||
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();
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
setSavedCss(css);
|
||||
setDraftCss(css);
|
||||
setCSS(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 (
|
||||
@@ -96,14 +81,9 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
||||
showCloseButton
|
||||
showBackdrop
|
||||
bodyElements={
|
||||
<div className={style.editorBody}>
|
||||
{!isLoadingCss && (
|
||||
<Suspense fallback={<Panel.Loader isLoading />}>
|
||||
<CodeEditor value={draftCss} onChange={setDraftCss} language='css' />
|
||||
</Suspense>
|
||||
)}
|
||||
<Panel.Loader isLoading={isLoadingCss} />
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
||||
</Suspense>
|
||||
}
|
||||
footerElements={
|
||||
<div className={style.column}>
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
declare module 'prismjs/components/prism-core' {
|
||||
export * from 'prismjs';
|
||||
}
|
||||
|
||||
declare module 'prismjs/components/prism-css';
|
||||
@@ -19,8 +19,7 @@ const staticOptions = [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__port', label: 'Server port' },
|
||||
{ id: 'settings__port', label: 'Server Port' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -208,28 +208,26 @@ export function MetadataTimes() {
|
||||
}
|
||||
|
||||
function GroupTimes() {
|
||||
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
|
||||
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
|
||||
const currentGroupId = useCurrentGroupId();
|
||||
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
||||
|
||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
||||
const active = isPlaybackActive(playback);
|
||||
|
||||
// the group end time does not encode any day offsets so it is calculated with group start time and duration
|
||||
// the group end time dose not encode any day offsets so it is calculated with group start time and duration
|
||||
const plannedGroupEnd = (() => {
|
||||
if (!hasRunningTimer) return null;
|
||||
if (!active) return null;
|
||||
if (!group || group.timeStart === null) return null;
|
||||
const normalizedClock = clock + currentDay * dayInMs;
|
||||
|
||||
if (mode === OffsetMode.Absolute) {
|
||||
return group.timeStart + group.duration - normalizedClock;
|
||||
}
|
||||
if (actualGroupStart === null) return null;
|
||||
return actualGroupStart + group.duration - normalizedClock;
|
||||
return mode === OffsetMode.Absolute
|
||||
? group.timeStart + group.duration - normalizedClock
|
||||
: actualGroupStart + group.duration - normalizedClock;
|
||||
})();
|
||||
|
||||
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
|
||||
|
||||
const expectedGroupEnd = hasRunningTimer && groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
||||
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
||||
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
|
||||
|
||||
return (
|
||||
@@ -240,7 +238,7 @@ function GroupTimes() {
|
||||
<span
|
||||
className={cx([
|
||||
style.time,
|
||||
(!group || !hasRunningTimer) && style.muted,
|
||||
(!group || !active) && style.muted,
|
||||
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||
])}
|
||||
>
|
||||
@@ -252,7 +250,7 @@ function GroupTimes() {
|
||||
<span
|
||||
className={cx([
|
||||
style.time,
|
||||
expectedGroupEnd === null && style.muted,
|
||||
!groupExpectedEnd && style.muted,
|
||||
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||
])}
|
||||
>
|
||||
@@ -264,27 +262,25 @@ function GroupTimes() {
|
||||
}
|
||||
|
||||
function FlagTimes() {
|
||||
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
|
||||
const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
|
||||
const { id, expectedStart } = useNextFlag();
|
||||
const entry = useEntry(id) as OntimeEvent | null;
|
||||
|
||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
||||
const active = isPlaybackActive(playback);
|
||||
|
||||
const plannedFlagStart = (() => {
|
||||
if (!hasRunningTimer) return null;
|
||||
if (!active) return null;
|
||||
if (!entry) return null;
|
||||
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
|
||||
const normalizedClock = clock + currentDay * dayInMs;
|
||||
if (mode === OffsetMode.Absolute) {
|
||||
return normalizedTimeStart - normalizedClock;
|
||||
}
|
||||
if (actualStart === null || plannedStart === null) return null;
|
||||
return normalizedTimeStart + actualStart - plannedStart - normalizedClock;
|
||||
return mode === OffsetMode.Absolute
|
||||
? normalizedTimeStart - normalizedClock
|
||||
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
|
||||
})();
|
||||
|
||||
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
|
||||
|
||||
const expectedTimeUntil = hasRunningTimer && expectedStart !== null ? expectedStart - clock : null;
|
||||
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
|
||||
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
|
||||
|
||||
const title = entry?.title ?? null;
|
||||
@@ -298,7 +294,7 @@ function FlagTimes() {
|
||||
data-testid='flag-plannedStart'
|
||||
className={cx([
|
||||
style.time,
|
||||
(!entry || !hasRunningTimer) && style.muted,
|
||||
(!entry || !active) && style.muted,
|
||||
plannedTimeUntilDisplay === 'due' && style.dueTime,
|
||||
])}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -50,8 +49,6 @@ 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);
|
||||
|
||||
@@ -266,7 +263,6 @@ 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}
|
||||
@@ -286,18 +282,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
[
|
||||
entries,
|
||||
metadata,
|
||||
getIsCollapsed,
|
||||
isEditMode,
|
||||
cursor,
|
||||
nextEventId,
|
||||
playback,
|
||||
lastEntryId,
|
||||
handleCollapseGroup,
|
||||
automationsEnabled,
|
||||
],
|
||||
[entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup],
|
||||
);
|
||||
|
||||
if (sortableData.length < 1) {
|
||||
|
||||
@@ -8,7 +8,6 @@ interface RundownEntryProps {
|
||||
type: SupportedEntry;
|
||||
isPast: boolean;
|
||||
data: OntimeEntry;
|
||||
automationsEnabled?: boolean;
|
||||
loaded: boolean;
|
||||
eventIndex: number;
|
||||
hasCursor: boolean;
|
||||
@@ -23,7 +22,6 @@ interface RundownEntryProps {
|
||||
export default function RundownEntry({
|
||||
isPast,
|
||||
data,
|
||||
automationsEnabled,
|
||||
loaded,
|
||||
hasCursor,
|
||||
isNext,
|
||||
@@ -54,7 +52,6 @@ export default function RundownEntry({
|
||||
title={data.title}
|
||||
note={data.note}
|
||||
delay={data.delay}
|
||||
automationsEnabled={automationsEnabled}
|
||||
colour={data.colour}
|
||||
isPast={isPast}
|
||||
isNext={isNext}
|
||||
|
||||
+12
-72
@@ -1,90 +1,30 @@
|
||||
.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 {
|
||||
padding-block: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
grid-template-columns: 8rem 1fr auto 2rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
align-items: center;
|
||||
min-height: 2.5rem;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid $white-10;
|
||||
&:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
}
|
||||
|
||||
& > span {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
.errorLabel {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.validationSuccess {
|
||||
.success {
|
||||
color: $green-500;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
|
||||
import { 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,36 +20,23 @@ 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 (
|
||||
<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>
|
||||
<>
|
||||
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
|
||||
<EventTriggerForm triggers={triggers} eventId={eventId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EventTriggerFormProps {
|
||||
eventId: string;
|
||||
triggers?: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
|
||||
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
|
||||
const { data: automationSettings } = useAutomationSettings();
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
||||
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
||||
@@ -65,7 +52,7 @@ function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormPr
|
||||
if (automationId === undefined) {
|
||||
return 'Select an automation';
|
||||
}
|
||||
if (!Object.keys(automations).includes(automationId)) {
|
||||
if (!Object.keys(automationSettings.automations).includes(automationId)) {
|
||||
return 'This automation does not exist';
|
||||
}
|
||||
if (triggers === undefined) {
|
||||
@@ -77,11 +64,10 @@ function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormPr
|
||||
};
|
||||
|
||||
const validationError = getValidationError(cycleValue, automationId);
|
||||
const validationLabel = validationError ?? 'Ready to add automation';
|
||||
|
||||
const triggerOptions = useMemo(
|
||||
() => [
|
||||
{ value: null, label: 'Select lifecycle' },
|
||||
{ value: null, label: 'Select Trigger' },
|
||||
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
|
||||
],
|
||||
[], // eventTriggerOptions is a constant, no need for dependency
|
||||
@@ -90,49 +76,42 @@ function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormPr
|
||||
const automationOptions = useMemo(
|
||||
() => [
|
||||
{ value: null, label: 'Select Automation' },
|
||||
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
|
||||
...Object.values(automationSettings.automations).map(({ id, title }) => ({ value: id, label: title })),
|
||||
],
|
||||
[automations], // This needs to be a dependency as it can change
|
||||
[automationSettings.automations], // This needs to be a dependency as it can change
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style.triggerForm}>
|
||||
<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={cycleValue}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) setCycleValue(value);
|
||||
}}
|
||||
options={triggerOptions}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Editor.Label>Automation</Editor.Label>
|
||||
<Select
|
||||
value={automationId ?? null}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) setAutomationId(value);
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={automationId ?? null}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) setAutomationId(value);
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -140,11 +119,11 @@ function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormPr
|
||||
interface ExistingEventTriggersProps {
|
||||
eventId: string;
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
|
||||
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const { data: automationSettings } = useAutomationSettings();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(triggerId: string) => {
|
||||
@@ -165,22 +144,16 @@ function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEvent
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={style.triggerList}>
|
||||
<div>
|
||||
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
||||
<Fragment key={triggerLifeCycle}>
|
||||
{triggerGroup.map((trigger) => {
|
||||
const { id, automationId } = trigger;
|
||||
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||
return (
|
||||
<div key={id} className={style.trigger}>
|
||||
<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>
|
||||
<Tag>{triggerLifeCycle}</Tag>
|
||||
<Tag>{automationTitle}</Tag>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
|
||||
@@ -231,10 +231,6 @@ $skip-opacity: 0.2;
|
||||
color: var(--status-color-active-override, $active-indicator);
|
||||
}
|
||||
|
||||
.statusIcon.warning {
|
||||
color: $orange-500;
|
||||
}
|
||||
|
||||
.statusIcon.disabled {
|
||||
color: $gray-1000;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ interface RundownEventProps {
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
automationsEnabled?: boolean;
|
||||
colour: string;
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
@@ -77,7 +76,6 @@ export default function RundownEvent({
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
automationsEnabled,
|
||||
colour,
|
||||
isPast,
|
||||
isNext,
|
||||
@@ -304,7 +302,6 @@ export default function RundownEvent({
|
||||
title={title}
|
||||
note={note}
|
||||
delay={delay}
|
||||
automationsEnabled={automationsEnabled}
|
||||
isNext={isNext}
|
||||
skip={skip}
|
||||
loaded={loaded}
|
||||
|
||||
@@ -38,7 +38,6 @@ interface RundownEventInnerProps {
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
automationsEnabled?: boolean;
|
||||
isNext: boolean;
|
||||
skip: boolean;
|
||||
loaded: boolean;
|
||||
@@ -65,7 +64,6 @@ function RundownEventInner({
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
automationsEnabled,
|
||||
isNext,
|
||||
skip = false,
|
||||
loaded,
|
||||
@@ -81,21 +79,6 @@ 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) {
|
||||
@@ -159,8 +142,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={automationTooltip} render={<span />}>
|
||||
<IoFlash className={automationIconClasses} />
|
||||
<Tooltip text='Event has Triggers' render={<span />}>
|
||||
<IoFlash className={`${style.statusIcon} ${hasTriggers ? style.active : style.disabled}`} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.qrCode {
|
||||
padding: 0.5rem;
|
||||
background: $ui-white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.column {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -9,7 +10,6 @@ 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} />
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<div className={style.copiableLink} data-testid='copy-link'>
|
||||
{url}
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { PropsWithChildren, Suspense } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import useCssOverride from '../common/hooks-query/useCssOverride';
|
||||
import { overrideStylesURL } from '../common/api/constants';
|
||||
import useViewSettings from '../common/hooks-query/useViewSettings';
|
||||
import { useRuntimeStylesheet } from '../common/hooks/useRuntimeStylesheet';
|
||||
import Loader from './common/loader/Loader';
|
||||
|
||||
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';
|
||||
const { data } = useViewSettings();
|
||||
const { shouldRender } = useRuntimeStylesheet(data.overrideStyles ? overrideStylesURL : undefined);
|
||||
|
||||
// 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 (
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OverrideStyles />
|
||||
<>
|
||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||
{children}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,12 @@
|
||||
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} />}
|
||||
{projectData.url && <QRCode value={projectData.url} size={qrSize} level='L' className='info-card__qr' />}
|
||||
{projectData.info && <div className='info-card__message'>{projectData.info}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-1
@@ -4,7 +4,6 @@
|
||||
background-color: transparent;
|
||||
border-radius: $component-border-radius-md;
|
||||
text-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
],
|
||||
"types": [
|
||||
"vite/client",
|
||||
"vite-plugin-svgr/client",
|
||||
"vite-plugin-prismjs-plus/client",
|
||||
"vite-plugin-svgr/client"
|
||||
],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
+13
-11
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -17,11 +16,6 @@ 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',
|
||||
@@ -54,11 +48,6 @@ 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,
|
||||
@@ -99,12 +88,25 @@ 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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.6.0",
|
||||
"version": "4.5.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.0.18",
|
||||
"electron-builder": "26.8.1",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -29,8 +29,7 @@
|
||||
"appId": "no.lightdev.ontime",
|
||||
"asar": true,
|
||||
"dmg": {
|
||||
"artifactName": "ontime-macOS-${arch}.dmg",
|
||||
"icon": "icon.icns"
|
||||
"artifactName": "ontime-macOS-${arch}.dmg"
|
||||
},
|
||||
"mac": {
|
||||
"notarize": true,
|
||||
@@ -46,7 +45,7 @@
|
||||
]
|
||||
},
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "icon.icns"
|
||||
"icon": "ontime.icon"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const {
|
||||
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, showDialog, download) {
|
||||
const template = [
|
||||
...(isMac ? [makeMacMenu(askToQuit)] : []),
|
||||
makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download),
|
||||
makeFileMenu(serverUrl, redirectWindow, showDialog, download),
|
||||
makeEditMenu(),
|
||||
makeViewMenu(clientUrl),
|
||||
makeSettingsMenu(redirectWindow),
|
||||
@@ -56,7 +56,7 @@ function makeMacMenu(askToQuit) {
|
||||
{
|
||||
label: 'Quit',
|
||||
click: askToQuit,
|
||||
accelerator: 'Cmd+Q',
|
||||
accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -86,7 +86,7 @@ function makeEditMenu() {
|
||||
* @param {function} download - function to download a resource from url
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download) {
|
||||
function makeFileMenu(serverUrl, redirectWindow, showDialog, download) {
|
||||
const downloadProject = () => {
|
||||
try {
|
||||
download(serverUrl + downloadPath);
|
||||
@@ -134,7 +134,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { label: 'Quit', click: askToQuit, accelerator: 'Alt+F4' },
|
||||
{ role: isMac ? 'close' : 'quit' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.6.0",
|
||||
"version": "4.5.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.6.0",
|
||||
"version": "4.5.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/bundleDefaults.ts",
|
||||
"prebuild": "tsx --tsconfig tsconfig.app.json ./scripts/bundleCss.ts && tsx --tsconfig tsconfig.app.json ./scripts/bundleTranslation.ts",
|
||||
"build": "node esbuild.js",
|
||||
"test": "cross-env IS_TEST=true vitest",
|
||||
"test:inspect": "cross-env IS_TEST=true vitest --inspect --no-file-parallelism",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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();
|
||||
@@ -1,31 +0,0 @@
|
||||
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();
|
||||
@@ -0,0 +1,20 @@
|
||||
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,3 +1,3 @@
|
||||
export interface IAdapter {
|
||||
shutdown: () => Promise<void>;
|
||||
shutdown: () => void;
|
||||
}
|
||||
|
||||
@@ -72,17 +72,10 @@ class OscServer implements IAdapter {
|
||||
});
|
||||
this.udpSocket.bind(port);
|
||||
}
|
||||
shutdown(): Promise<void> {
|
||||
shutdown() {
|
||||
logger.info(LogOrigin.Rx, 'OSC: Closing server');
|
||||
const socket = this.udpSocket;
|
||||
this.udpSocket?.close();
|
||||
this.udpSocket = null;
|
||||
if (!socket) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
socket.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -223,9 +223,14 @@ class SocketServer implements IAdapter {
|
||||
|
||||
// message is any serializable value
|
||||
public sendAsJson<T extends MessageTag>(tag: T, payload: Pick<WsPacketToClient & { tag: T }, 'payload'>['payload']) {
|
||||
const wss = this.wss;
|
||||
if (!wss || wss.clients.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stringifiedMessage = JSON.stringify({ tag, payload });
|
||||
this.wss?.clients.forEach((client) => {
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringifiedMessage);
|
||||
}
|
||||
@@ -235,25 +240,8 @@ class SocketServer implements IAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
shutdown() {
|
||||
this.wss?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '../../bundle/bundledCss.js';
|
||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
|
||||
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
|
||||
|
||||
@@ -24,7 +24,6 @@ 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);
|
||||
@@ -35,7 +34,6 @@ 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,7 +4,6 @@ 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;
|
||||
@@ -41,16 +40,6 @@ 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({
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
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) });
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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' });
|
||||
});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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,
|
||||
];
|
||||
@@ -1,37 +0,0 @@
|
||||
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,18 +21,6 @@ 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
|
||||
@@ -105,8 +93,7 @@ export function generateExcelFile(rundown: Rundown, customFields: CustomFields):
|
||||
|
||||
const workbook = xlsx.utils.book_new();
|
||||
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
|
||||
const worksheetName = getValidWorksheetName(rundown.title || 'Rundown');
|
||||
xlsx.utils.book_append_sheet(workbook, worksheet, worksheetName);
|
||||
xlsx.utils.book_append_sheet(workbook, worksheet, rundown.title || 'Rundown');
|
||||
|
||||
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -19,7 +18,6 @@ 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);
|
||||
|
||||
@@ -186,7 +186,8 @@ function remove(rundown: Rundown, entry: OntimeEntry) {
|
||||
// for ontime groups, we need to iterate through the children and delete them
|
||||
for (let i = 0; i < entry.entries.length; i++) {
|
||||
const nestedEntryId = entry.entries[i];
|
||||
deleteEntry(nestedEntryId);
|
||||
// nested entries are not part of the top-level order
|
||||
deleteEntry(nestedEntryId, false);
|
||||
}
|
||||
} else if (entry.parent) {
|
||||
// at this point, we are handling entries inside a group, so we need to remove the reference
|
||||
@@ -205,10 +206,13 @@ function remove(rundown: Rundown, entry: OntimeEntry) {
|
||||
edit(rundown, { id: parentGroup.id, entries: filteredEvents });
|
||||
}
|
||||
}
|
||||
|
||||
deleteEntry(entry.id);
|
||||
|
||||
function deleteEntry(idToDelete: EntryId) {
|
||||
rundown.order = deleteById(rundown.order, idToDelete);
|
||||
function deleteEntry(idToDelete: EntryId, shouldDeleteFromOrder: boolean = true) {
|
||||
if (shouldDeleteFromOrder) {
|
||||
rundown.order = deleteById(rundown.order, idToDelete);
|
||||
}
|
||||
delete rundown.entries[idToDelete];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,6 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
|
||||
let batchDidInvalidate = false;
|
||||
const changedIds: EntryId[] = [];
|
||||
const patchedEntries: OntimeEntry[] = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const currentId = ids[i];
|
||||
const currentEntry = rundown.entries[currentId];
|
||||
@@ -165,15 +164,20 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
continue;
|
||||
}
|
||||
|
||||
const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
|
||||
const { didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
|
||||
|
||||
changedIds.push(currentId);
|
||||
patchedEntries.push(entry);
|
||||
|
||||
if (didInvalidate) {
|
||||
batchDidInvalidate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// skip commit and notifications when the patch is no-op.
|
||||
if (changedIds.length === 0) {
|
||||
return getCurrentRundown() as Rundown;
|
||||
}
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit(batchDidInvalidate);
|
||||
|
||||
// schedule the side effects
|
||||
@@ -192,7 +196,14 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
* Deletes a known entry from the current rundown
|
||||
*/
|
||||
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
const currentRundown = getCurrentRundown();
|
||||
const shouldDelete = entryIds.some((entryId) => Object.hasOwn(currentRundown.entries, entryId));
|
||||
if (!shouldDelete) {
|
||||
return currentRundown as Rundown;
|
||||
}
|
||||
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
const deletedIds: EntryId[] = [];
|
||||
|
||||
for (let i = 0; i < entryIds.length; i++) {
|
||||
const entry = rundown.entries[entryIds[i]];
|
||||
@@ -200,6 +211,11 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
continue;
|
||||
}
|
||||
rundownMutation.remove(rundown, entry);
|
||||
deletedIds.push(entry.id);
|
||||
}
|
||||
|
||||
if (deletedIds.length === 0) {
|
||||
return currentRundown as Rundown;
|
||||
}
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
@@ -210,7 +226,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
|
||||
notifyChanges(rundownMetadata, revision, { timer: deletedIds, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -571,8 +587,10 @@ type NotifyChangesOptions = {
|
||||
* Notify services of changes in the rundown
|
||||
*/
|
||||
function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
|
||||
const shouldNotifyTimer = options.timer === true || (Array.isArray(options.timer) && options.timer.length > 0);
|
||||
|
||||
// notify timer service of changed events
|
||||
if (options.timer) {
|
||||
if (shouldNotifyTimer) {
|
||||
runtimeService.notifyOfChangedEvents(rundownMetadata);
|
||||
}
|
||||
|
||||
|
||||
+24
-90
@@ -42,7 +42,6 @@ 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}`);
|
||||
@@ -65,7 +64,6 @@ const prefix = updateRouterPrefix();
|
||||
|
||||
// Create express APP
|
||||
const app = express();
|
||||
let isShuttingDown = false;
|
||||
if (!isProduction) {
|
||||
// log server timings to requests
|
||||
app.use(serverTiming());
|
||||
@@ -87,15 +85,6 @@ 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
|
||||
@@ -147,7 +136,6 @@ enum OntimeStartOrder {
|
||||
|
||||
let step = OntimeStartOrder.InitAssets;
|
||||
let expressServer: Server | null = null;
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
|
||||
const checkStart = (currentState: OntimeStartOrder) => {
|
||||
if (step !== currentState) {
|
||||
@@ -262,89 +250,35 @@ export const startIntegrations = async () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean shutdown app services
|
||||
* - it avoid concurrency issues with deduplication of request to shutdown
|
||||
* - extracts exit code to modify cleanup behaviour
|
||||
* @description clean shutdown app services
|
||||
* @param {number} exitCode
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
export async function shutdown(exitCode = 0): Promise<void> {
|
||||
if (shutdownPromise) {
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
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;
|
||||
export const shutdown = async (exitCode = 0) => {
|
||||
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);
|
||||
await flushPendingWrites().catch((_error) => {
|
||||
/** nothing do to here */
|
||||
});
|
||||
|
||||
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);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
expressServer?.close();
|
||||
runtimeService.shutdown();
|
||||
logger.shutdown();
|
||||
oscServer.shutdown();
|
||||
socket.shutdown();
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
|
||||
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
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>`;
|
||||
Vendored
-406
@@ -8,409 +8,3 @@ 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
@@ -0,0 +1,405 @@
|
||||
## 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/)
|
||||
Vendored
+157
@@ -0,0 +1,157 @@
|
||||
/*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
@@ -0,0 +1,116 @@
|
||||
<!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
@@ -0,0 +1,91 @@
|
||||
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,6 +1,6 @@
|
||||
import { copyFileSync, existsSync, writeFileSync } from 'fs';
|
||||
|
||||
import { defaultTranslation } from '../bundle/bundledTranslations.js';
|
||||
import { defaultTranslation } from '../user/translations/bundledTranslations.js';
|
||||
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||
import { publicDir, publicFiles, srcFiles } from './index.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Dirent, PathLike, type Stats, constants, existsSync, mkdirSync } from 'fs';
|
||||
import { access, copyFile, mkdir, readdir, rm, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { PathLike, constants, existsSync, mkdirSync } from 'fs';
|
||||
import { copyFile, readdir, unlink } from 'fs/promises';
|
||||
import { basename, join, parse } from 'path';
|
||||
|
||||
import { consoleError } from './console.js';
|
||||
@@ -152,77 +152,3 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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('/editor');
|
||||
await page.goto('http://localhost:4001/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('/editor/?settings=project__manage');
|
||||
await page.goto('http://localhost:4001/editor/?settings=project__manage');
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: /.*currently loaded/i })
|
||||
|
||||
@@ -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('/');
|
||||
await page.goto('http://localhost:4001/');
|
||||
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('/timeline');
|
||||
await expect(page).toHaveURL('http://localhost:4001/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('/backstage');
|
||||
await expect(page).toHaveURL('http://localhost:4001/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('/studio');
|
||||
await expect(page).toHaveURL('http://localhost:4001/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('/countdown');
|
||||
await expect(page).toHaveURL('http://localhost:4001/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('/info');
|
||||
await expect(page).toHaveURL('http://localhost:4001/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('/timer');
|
||||
await expect(page).toHaveURL('http://localhost:4001/timer');
|
||||
});
|
||||
|
||||
test('not-found', async ({ page }) => {
|
||||
await page.goto('/not-found');
|
||||
await page.goto('http://localhost:4001/not-found');
|
||||
|
||||
await expect(page).toHaveTitle(/ontime/);
|
||||
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
|
||||
|
||||
await page.goto('/preset/not-found');
|
||||
await page.goto('http://localhost:4001/preset/not-found');
|
||||
|
||||
await expect(page).toHaveTitle(/ontime/);
|
||||
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user