mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c6acc4013 | |||
| 26a24449de | |||
| a1fb642441 | |||
| b64b154330 | |||
| 5032cbf65a | |||
| 466360f9d1 | |||
| ef2ea9a1da | |||
| fb83f48752 | |||
| c1d53b0e55 | |||
| 7915cc822d | |||
| 7dbf64d100 | |||
| 6ffbf2af9d | |||
| fa7ec623f1 | |||
| bfb9b51073 | |||
| 8bc3f5a56c | |||
| acf1afb5e9 | |||
| d8e2a8d092 | |||
| 8e587128a1 | |||
| 607bc40673 | |||
| e6aa7404f7 | |||
| d622a7738f | |||
| b9ba416366 |
@@ -39,6 +39,7 @@ dist/
|
||||
# docker utils
|
||||
ontime-db
|
||||
ontime-external/
|
||||
ontime-data/
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime .`
|
||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||
|
||||
Other useful commands
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.5",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
+25
-29
@@ -1,31 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<base href="/">
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta name='theme-color' content='#101010' />
|
||||
<meta name='ontime' content='ontime - time keeping for live events' />
|
||||
<link rel='apple-touch-icon' href='/ontime-logo.png' />
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
href='ontime-logo.png'
|
||||
/>
|
||||
<link rel='manifest' href='/site.webmanifest' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id='root'></div>
|
||||
<script type='module' src='/src/index.tsx'></script>
|
||||
</body>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#101010" />
|
||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||
<link rel="apple-touch-icon" href="/ontime-logo.png" />
|
||||
<link rel="icon" type="image/png" href="/ontime-logo.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -39,7 +39,7 @@
|
||||
"build": "vite build",
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "cross-env VITE_IS_CLOUD=true vite build",
|
||||
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
|
||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"test": "vitest",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
import { baseURI } from './externals';
|
||||
|
||||
connectSocket();
|
||||
|
||||
@@ -19,7 +20,7 @@ function App() {
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={baseURI}>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||
|
||||
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { isLocalhost, serverPort } from '../../../externals';
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||
@@ -25,7 +25,7 @@ import useInfo from '../../hooks-query/useInfo';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { handleLinks, openLink } from '../../utils/linkUtils';
|
||||
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import CopyTag from '../copy-tag/CopyTag';
|
||||
@@ -45,7 +45,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
|
||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const location = useLocation();
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -65,7 +65,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<DrawerBody padding={0}>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
className={cx([style.link, fullscreen && style.current])}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={toggle}
|
||||
@@ -77,7 +77,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
className={cx([style.link, mirror && style.current])}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={() => toggleMirror()}
|
||||
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
/* share the same style as a page layout */
|
||||
.page {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import Empty from './Empty';
|
||||
|
||||
import style from './EmptyPage.module.scss';
|
||||
|
||||
interface EmptyPageProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function EmptyPage(props: EmptyPageProps) {
|
||||
const { text, ...rest } = props;
|
||||
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<Empty text={text} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,17 @@ export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
|
||||
|
||||
/**
|
||||
* Allows patching a property of the runtime store
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
|
||||
export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
|
||||
const state = runtimeStore.getState();
|
||||
state[key] = value;
|
||||
runtimeStore.setState({ ...state });
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows patching the entire runtime store
|
||||
*/
|
||||
export function patchRuntime(patch: Partial<RuntimeStore>) {
|
||||
const state = runtimeStore.getState();
|
||||
runtimeStore.setState({ ...state, ...patch });
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Returns hostname
|
||||
* @type {string}
|
||||
*/
|
||||
export const host = window?.location?.host;
|
||||
|
||||
/**
|
||||
* Open an external URLs: specifically for a electron / browser case
|
||||
* If electron: ask main process to call a new browser window
|
||||
* If browser: open in new tab
|
||||
* @param url
|
||||
*/
|
||||
export function openLink(url) {
|
||||
if (window.process?.type === 'renderer') {
|
||||
window.ipcRenderer.send('send-to-link', url);
|
||||
} else {
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles opening external links
|
||||
* @param event
|
||||
* @param location
|
||||
*/
|
||||
export function handleLinks(event, location) {
|
||||
// we handle the link manually
|
||||
event.preventDefault();
|
||||
openLink(`http://${host}/${location}`);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { baseURI, serverURL } from '../../externals';
|
||||
|
||||
/**
|
||||
* Open an external URLs: specifically for a electron / browser case
|
||||
* If electron: ask main process to call a new browser window
|
||||
* If browser: open in new tab
|
||||
* @param url
|
||||
*/
|
||||
export function openLink(url: string) {
|
||||
if (window.process?.type === 'renderer') {
|
||||
window.ipcRenderer.send('send-to-link', url);
|
||||
} else {
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles opening external links
|
||||
* @param event
|
||||
* @param location
|
||||
*/
|
||||
export function handleLinks(event: MouseEvent, location: string) {
|
||||
// we handle the link manually
|
||||
event.preventDefault();
|
||||
|
||||
const destination = new URL(serverURL);
|
||||
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
|
||||
openLink(destination.toString());
|
||||
}
|
||||
|
||||
export function linkToOtherHost(host: string, path?: string) {
|
||||
const destination = new URL(serverURL);
|
||||
destination.hostname = host;
|
||||
if (path) {
|
||||
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||
}
|
||||
return destination.toString();
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../stores/clientStore';
|
||||
import { addDialog } from '../stores/dialogStore';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
||||
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
|
||||
export let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -39,12 +39,14 @@ export const connectSocket = () => {
|
||||
}
|
||||
|
||||
socketSendJson('set-client-type', 'ontime');
|
||||
|
||||
socketSendJson('set-client-path', location.pathname + location.search);
|
||||
setOnlineStatus(true);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.warn('WebSocket disconnected');
|
||||
setOnlineStatus(false);
|
||||
|
||||
if (shouldReconnect) {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
console.warn('WebSocket: attempting reconnect');
|
||||
@@ -73,8 +75,8 @@ export const connectSocket = () => {
|
||||
switch (type) {
|
||||
case 'pong': {
|
||||
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||
patchRuntime('ping', offset);
|
||||
updateDevTools({ ping: offset }, ['PING']);
|
||||
patchRuntimeProperty('ping', offset);
|
||||
updateDevTools({ ping: offset });
|
||||
break;
|
||||
}
|
||||
case 'client-id': {
|
||||
@@ -131,64 +133,65 @@ export const connectSocket = () => {
|
||||
break;
|
||||
}
|
||||
case 'ontime': {
|
||||
runtimeStore.setState(payload as RuntimeStore);
|
||||
if (!isProduction) {
|
||||
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
|
||||
const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
|
||||
|
||||
patchRuntime(serverPayload);
|
||||
updateDevTools(serverPayload);
|
||||
break;
|
||||
}
|
||||
case 'ontime-clock': {
|
||||
patchRuntime('clock', payload);
|
||||
patchRuntimeProperty('clock', payload);
|
||||
updateDevTools({ clock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-timer': {
|
||||
patchRuntime('timer', payload);
|
||||
patchRuntimeProperty('timer', payload);
|
||||
updateDevTools({ timer: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-onAir': {
|
||||
patchRuntime('onAir', payload);
|
||||
patchRuntimeProperty('onAir', payload);
|
||||
updateDevTools({ onAir: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-message': {
|
||||
patchRuntime('message', payload);
|
||||
patchRuntimeProperty('message', payload);
|
||||
updateDevTools({ message: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-runtime': {
|
||||
patchRuntime('runtime', payload);
|
||||
patchRuntimeProperty('runtime', payload);
|
||||
updateDevTools({ runtime: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNow': {
|
||||
patchRuntime('eventNow', payload);
|
||||
patchRuntimeProperty('eventNow', payload);
|
||||
updateDevTools({ eventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-currentBlock': {
|
||||
patchRuntime('currentBlock', payload);
|
||||
patchRuntimeProperty('currentBlock', payload);
|
||||
updateDevTools({ currentBlock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNow': {
|
||||
patchRuntime('publicEventNow', payload);
|
||||
patchRuntimeProperty('publicEventNow', payload);
|
||||
updateDevTools({ publicEventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNext': {
|
||||
patchRuntime('eventNext', payload);
|
||||
patchRuntimeProperty('eventNext', payload);
|
||||
updateDevTools({ eventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNext': {
|
||||
patchRuntime('publicEventNext', payload);
|
||||
patchRuntimeProperty('publicEventNext', payload);
|
||||
updateDevTools({ publicEventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-auxtimer1': {
|
||||
patchRuntime('auxtimer1', payload);
|
||||
patchRuntimeProperty('auxtimer1', payload);
|
||||
updateDevTools({ auxtimer1: payload });
|
||||
break;
|
||||
}
|
||||
@@ -232,11 +235,23 @@ export const socketSendJson = (type: string, payload?: unknown) => {
|
||||
);
|
||||
};
|
||||
|
||||
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
|
||||
function updateDevTools(newData: Partial<RuntimeStore>) {
|
||||
if (!isProduction) {
|
||||
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
|
||||
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
|
||||
...oldData,
|
||||
...newData,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting the status of the client
|
||||
* We leverage the ping as an indication of the client's online status
|
||||
* @example ping < 0 - client is offline
|
||||
* @example ping > 0 -> client is online
|
||||
*/
|
||||
function setOnlineStatus(status: boolean) {
|
||||
const derivedPing = status ? 1 : -1;
|
||||
patchRuntimeProperty('ping', derivedPing);
|
||||
updateDevTools({ ping: derivedPing });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { version } from '../../../package.json';
|
||||
/**
|
||||
* This file contains a list of constants that may need to be resolved at runtime
|
||||
*/
|
||||
|
||||
import { version } from '../../../package.json';
|
||||
|
||||
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||
export const websiteUrl = 'https://www.getontime.no';
|
||||
@@ -16,13 +17,59 @@ export const appVersion = version;
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD);
|
||||
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
|
||||
|
||||
// resolve protocol
|
||||
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
// resolve entrypoint URLs
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? window.location.port : STATIC_PORT;
|
||||
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
/**
|
||||
* Resolve base
|
||||
* @example '' for electron
|
||||
* @example '/client-hash' for cloud
|
||||
*/
|
||||
export const baseURI = resolveBaseURI();
|
||||
export const serverURL = resolveUrl('http', '');
|
||||
export const websocketUrl = resolveUrl('ws', 'ws');
|
||||
|
||||
function resolveUrl(protocol: 'http' | 'ws', path: string) {
|
||||
const url = new URL(window.location.origin);
|
||||
|
||||
// generate ws url
|
||||
if (protocol === 'ws') {
|
||||
// ensure we remain in a secure context
|
||||
const isSecure = window.location.protocol === 'https:';
|
||||
url.protocol = isSecure ? 'wss' : 'ws';
|
||||
}
|
||||
|
||||
// make path name relative to the base URI
|
||||
url.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||
|
||||
// in development mode, we use the React port for UI, but need the requests to target the server
|
||||
if (isDev) {
|
||||
// this is used as a fallback port for development
|
||||
url.port = '4001';
|
||||
}
|
||||
|
||||
const result = url.toString();
|
||||
|
||||
// prevent trailing slash
|
||||
return result.endsWith('/') ? result.slice(0, -1) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a base URI for a client that is not at the root segment
|
||||
* ie: https://cloud.getontime.com/client-hash/timer
|
||||
* This is necessary for ontime cloud and should otherwise not affect the client
|
||||
*/
|
||||
function resolveBaseURI(): string {
|
||||
// in ontime cloud, the base tag is set by the server
|
||||
const baseHref = document.querySelector('base')?.getAttribute('href');
|
||||
const base = baseHref ?? '';
|
||||
|
||||
// prevent a trailing slash from either an empty base or a base with a trailing slash
|
||||
if (base.endsWith('/')) {
|
||||
return base.slice(0, -1);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
@@ -106,6 +107,9 @@ export default function OscIntegrations() {
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
{isOntimeCloud && (
|
||||
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
|
||||
)}
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
import { isLocalhost, serverPort } from '../../../../externals';
|
||||
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
|
||||
import { isLocalhost } from '../../../../externals';
|
||||
|
||||
import style from './NetworkInterfaces.module.scss';
|
||||
|
||||
@@ -14,11 +14,11 @@ export default function InfoNif() {
|
||||
|
||||
return (
|
||||
<div className={style.interfaces}>
|
||||
{data.networkInterfaces?.map((nif) => {
|
||||
{data.networkInterfaces.map((nif) => {
|
||||
// interfaces outside localhost wont have access
|
||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||
const address = linkToOtherHost(nif.address);
|
||||
|
||||
const address = `http://${nif.address}:${serverPort}`;
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { socketSendJson } from '../../../../common/utils/socket';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import { isDockerImage } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||
@@ -19,7 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
<Panel.Section>
|
||||
{isOntimeCloud && <OntimeCloudStats />}
|
||||
{isDockerImage && <OntimeCloudStats />}
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<InfoNif />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { PROJECT_LIST } from '../../../../common/api/constants';
|
||||
import { createProject } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
@@ -118,7 +119,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='www.getontime.no'
|
||||
placeholder={websiteUrl}
|
||||
autoComplete='off'
|
||||
{...register('publicUrl')}
|
||||
/>
|
||||
@@ -140,7 +141,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='http://docs.getontime.no'
|
||||
placeholder={documentationUrl}
|
||||
autoComplete='off'
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { postProjectData, uploadProjectLogo } from '../../../../common/api/proje
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
@@ -203,7 +204,7 @@ export default function ProjectData() {
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='www.getontime.no'
|
||||
placeholder={websiteUrl}
|
||||
autoComplete='off'
|
||||
{...register('publicUrl')}
|
||||
/>
|
||||
@@ -225,7 +226,7 @@ export default function ProjectData() {
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='http://docs.getontime.no'
|
||||
placeholder={documentationUrl}
|
||||
autoComplete='off'
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||
import { isLocalhost } from '../../../../externals';
|
||||
import { isLocalhost, isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function ShutdownPanel() {
|
||||
@@ -24,18 +24,28 @@ export default function ShutdownPanel() {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const canShutdown = isElectron || isLocalhost;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Shutdown Ontime</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>
|
||||
This will shutdown the Ontime server. <br />
|
||||
The runtime state will be lost, but your project is kept for next time.
|
||||
</Panel.Paragraph>
|
||||
{isOntimeCloud ? (
|
||||
<Panel.Highlight>
|
||||
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
|
||||
</Panel.Highlight>
|
||||
) : (
|
||||
<Panel.Paragraph>
|
||||
This will shutdown the Ontime server. <br />
|
||||
The runtime state will be lost, but your project is kept for next time.
|
||||
</Panel.Paragraph>
|
||||
)}
|
||||
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
|
||||
Shutdown ontime
|
||||
</Button>
|
||||
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
||||
{!canShutdown && (
|
||||
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
||||
)}
|
||||
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
@@ -49,7 +59,7 @@ export default function ShutdownPanel() {
|
||||
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme='red' onClick={sendShutdown} ml={4}>
|
||||
<Button colorScheme='red' onClick={sendShutdown} disabled={!canShutdown}>
|
||||
Shutdown
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
[
|
||||
[
|
||||
"Ontime · Rundown export",
|
||||
],
|
||||
[
|
||||
"Project title: test title",
|
||||
],
|
||||
[
|
||||
"Project description: test description",
|
||||
],
|
||||
[
|
||||
"Time Start",
|
||||
"Time End",
|
||||
"Duration",
|
||||
"ID",
|
||||
"Colour",
|
||||
"Cue",
|
||||
"Title",
|
||||
"Note",
|
||||
"Is Public? (x)",
|
||||
"Skip?",
|
||||
"lighting",
|
||||
],
|
||||
[
|
||||
"00:00:00",
|
||||
"00:00:00",
|
||||
"...",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"test title 1",
|
||||
"",
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
]
|
||||
`;
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
height: 350px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Welcome(props: WelcomeProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={handleClose} variant='ontime'>
|
||||
<Modal isOpen onClose={handleClose} closeOnOverlayClick={false} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth='max(640px, 40vw)'>
|
||||
<ModalCloseButton />
|
||||
|
||||
@@ -149,6 +149,7 @@ $orange-active: #f60;
|
||||
grid-area: schedule;
|
||||
font-family: monospace;
|
||||
margin-right: 2vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.onAir {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
|
||||
import useColumnManager from './useColumnManager';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
+4
-4
@@ -11,16 +11,16 @@ import { useCuesheet } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import { CuesheetOverview } from '../overview/Overview';
|
||||
import { CuesheetOverview } from '../../features/overview/Overview';
|
||||
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
|
||||
import Cuesheet from './Cuesheet';
|
||||
import { makeCuesheetColumns } from './cuesheetCols';
|
||||
|
||||
import styles from './CuesheetWrapper.module.scss';
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetWrapper() {
|
||||
export default function CuesheetPage() {
|
||||
// TODO: can we use the normalised rundown for the table?
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields } = useCustomFields();
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
|
||||
import CuesheetWrapper from './CuesheetWrapper';
|
||||
import CuesheetPage from './CuesheetPage';
|
||||
|
||||
export default function ProtectedCuesheet() {
|
||||
return (
|
||||
<ProtectRoute permission='operator'>
|
||||
<CuesheetWrapper />
|
||||
<CuesheetPage />
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
+47
-3
@@ -1,4 +1,6 @@
|
||||
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable, parseField } from '../cuesheet.utils';
|
||||
|
||||
describe('parseField()', () => {
|
||||
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
|
||||
@@ -27,6 +29,7 @@ describe('parseField()', () => {
|
||||
});
|
||||
|
||||
it('returns an empty string on undefined fields', () => {
|
||||
// @ts-expect-error -- testing user data with missing fields
|
||||
expect(parseField('title')).toBe('');
|
||||
});
|
||||
|
||||
@@ -51,6 +54,7 @@ describe('makeTable()', () => {
|
||||
const headerData = {
|
||||
title: 'test title',
|
||||
description: 'test description',
|
||||
projectLogo: 'test logo',
|
||||
};
|
||||
const tableData = [
|
||||
{
|
||||
@@ -66,8 +70,48 @@ describe('makeTable()', () => {
|
||||
lighting: { label: 'test' },
|
||||
};
|
||||
|
||||
const table = makeTable(headerData, tableData, customFields);
|
||||
expect(table).toMatchSnapshot();
|
||||
// @ts-expect-error -- testing user data with missing fields
|
||||
const table = makeTable(headerData as ProjectData, tableData, customFields);
|
||||
expect(table).not.toContain('test logo');
|
||||
expect(table).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"Ontime · Rundown export",
|
||||
],
|
||||
[
|
||||
"Project title: test title",
|
||||
],
|
||||
[
|
||||
"Project description: test description",
|
||||
],
|
||||
[
|
||||
"Time Start",
|
||||
"Time End",
|
||||
"Duration",
|
||||
"ID",
|
||||
"Colour",
|
||||
"Cue",
|
||||
"Title",
|
||||
"Note",
|
||||
"Is Public? (x)",
|
||||
"Skip?",
|
||||
"lighting",
|
||||
],
|
||||
[
|
||||
"00:00:00",
|
||||
"00:00:00",
|
||||
"...",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"test title 1",
|
||||
"",
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon'
|
||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||
import { cx, enDash } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
import { useCuesheetSettings } from '../store/cuesheetSettingsStore';
|
||||
|
||||
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { useClock, useTimer } from '../../../common/hooks/useSocket';
|
||||
import ClockTime from '../../viewers/common/clock-time/ClockTime';
|
||||
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
||||
import ClockTime from '../../../features/viewers/common/clock-time/ClockTime';
|
||||
import RunningTime from '../../../features/viewers/common/running-time/RunningTime';
|
||||
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
import { useCuesheetSettings } from '../store/cuesheetSettingsStore';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@ import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||
import RunningTime from '../viewers/common/running-time/RunningTime';
|
||||
import RunningTime from '../../features/viewers/common/running-time/RunningTime';
|
||||
|
||||
import EditableCell from './cuesheet-table-elements/EditableCell';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
|
||||
|
||||
interface CuesheetSettings {
|
||||
interface CuesheetSettingsStore {
|
||||
showSettings: boolean;
|
||||
showIndexColumn: boolean;
|
||||
followSelected: boolean;
|
||||
@@ -36,7 +36,7 @@ enum CuesheetKeys {
|
||||
Seconds = 'ontime-cuesheet-hide-sceconds',
|
||||
}
|
||||
|
||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
export const useCuesheetSettings = create<CuesheetSettingsStore>()((set) => ({
|
||||
showSettings: false,
|
||||
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
|
||||
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
@@ -16,7 +17,7 @@ interface ProjectInfoProps {
|
||||
isMirrored: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectInfoProps(props: ProjectInfoProps) {
|
||||
export default function ProjectInfo(props: ProjectInfoProps) {
|
||||
const { general, isMirrored } = props;
|
||||
|
||||
useWindowTitle('Project info');
|
||||
@@ -25,6 +26,25 @@ export default function ProjectInfoProps(props: ProjectInfoProps) {
|
||||
return <Empty text='No data found' />;
|
||||
}
|
||||
|
||||
if (!general) {
|
||||
return (
|
||||
<>
|
||||
<ViewParamsEditor viewOptions={projectInfoOptions} />
|
||||
return <EmptyPage text='No data found' />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = Object.values(general).every((value) => !value);
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<>
|
||||
<ViewParamsEditor viewOptions={projectInfoOptions} />
|
||||
<EmptyPage text='The project has no data yet' />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
||||
<ViewParamsEditor viewOptions={projectInfoOptions} />
|
||||
|
||||
@@ -11,6 +11,7 @@ const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
const isDev = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development';
|
||||
|
||||
export default defineConfig({
|
||||
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
|
||||
plugins: [
|
||||
react(),
|
||||
svgrPlugin(),
|
||||
@@ -33,7 +34,7 @@ export default defineConfig({
|
||||
}),
|
||||
compression({
|
||||
algorithm: 'brotliCompress',
|
||||
exclude: /\.(html)$/, // Exclude HTML files from compression so we can change the base property at runtime
|
||||
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.5",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -29,6 +29,7 @@ function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, sho
|
||||
const template = [
|
||||
...(isMac ? [makeMacMenu(askToQuit)] : []),
|
||||
makeFileMenu(serverUrl, redirectWindow, showDialog, download),
|
||||
makeEditMenu(),
|
||||
makeViewMenu(clientUrl),
|
||||
makeSettingsMenu(redirectWindow),
|
||||
makeHelpMenu(redirectWindow),
|
||||
@@ -61,6 +62,22 @@ function makeMacMenu(askToQuit) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the edit menu
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeEditMenu() {
|
||||
return {
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||||
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
||||
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
|
||||
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the file menu
|
||||
* @param {string} serverUrl - base url for the application
|
||||
@@ -253,7 +270,7 @@ function makeSettingsMenu(redirectWindow) {
|
||||
click: () => redirectWindow('/editor?settings=network__log'),
|
||||
},
|
||||
{
|
||||
label: 'Manage cleints',
|
||||
label: 'Manage clients',
|
||||
click: () => redirectWindow('/editor?settings=network__clients'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.5",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -47,8 +47,8 @@ export class SocketServer implements IAdapter {
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
init(server: Server) {
|
||||
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
|
||||
init(server: Server, prefix?: string) {
|
||||
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
const clientId = generateId();
|
||||
|
||||
+38
-27
@@ -6,11 +6,11 @@ import expressStaticGzip from 'express-static-gzip';
|
||||
import http, { type Server } from 'http';
|
||||
import cors from 'cors';
|
||||
import serverTiming from 'server-timing';
|
||||
import { extname, resolve } from 'path';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
// import utils
|
||||
import { publicDir, srcDir } from './setup/index.js';
|
||||
import { environment, isProduction, updateRouterPrefix } from './externals.js';
|
||||
import { publicDir, srcDir, srcFiles } from './setup/index.js';
|
||||
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
|
||||
|
||||
@@ -53,8 +53,14 @@ if (!canLog) {
|
||||
console.log(`Ontime public directory at ${publicDir.root} `);
|
||||
}
|
||||
|
||||
// calls an update to the client router prefix
|
||||
updateRouterPrefix();
|
||||
/**
|
||||
* When running in Ontime cloud, the client is not at the root segment
|
||||
* ie: https://cloud.getontime.com/client-hash/timer
|
||||
* This means:
|
||||
* - changing the base path in the index.html file
|
||||
* - prepending all express routes with the given prefix
|
||||
*/
|
||||
const prefix = updateRouterPrefix();
|
||||
|
||||
// Create express APP
|
||||
const app = express();
|
||||
@@ -75,20 +81,20 @@ app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Implement route endpoints
|
||||
app.use('/data', appRouter); // router for application data
|
||||
app.use('/api', integrationRouter); // router for integrations
|
||||
app.use(`${prefix}/data`, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, integrationRouter); // router for integrations
|
||||
|
||||
// serve static external files
|
||||
app.use('/external', express.static(publicDir.externalDir));
|
||||
app.use('/user', express.static(publicDir.userDir));
|
||||
|
||||
// if the user reaches to the root, we show a 404
|
||||
app.use('/external', (req, res) => {
|
||||
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
|
||||
app.use(`${prefix}/external`, (req, res) => {
|
||||
// if the user reaches to the root, we show a 404
|
||||
res.status(404).send(`${req.originalUrl} not found`);
|
||||
});
|
||||
app.use(`${prefix}/user`, express.static(publicDir.userDir));
|
||||
|
||||
// serve static - react, in dev/test mode we fetch the React app from module
|
||||
app.use(
|
||||
prefix,
|
||||
expressStaticGzip(srcDir.clientDir, {
|
||||
enableBrotli: true,
|
||||
orderPreference: ['br'],
|
||||
@@ -111,8 +117,8 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(resolve(srcDir.clientDir, 'index.html'));
|
||||
app.get(`${prefix}/*`, (_req, res) => {
|
||||
res.sendFile(srcFiles.clientIndexHtml);
|
||||
});
|
||||
|
||||
// Implement catch all
|
||||
@@ -176,7 +182,7 @@ export const startServer = async (
|
||||
const { serverPort } = getDataProvider().getSettings();
|
||||
|
||||
expressServer = http.createServer(app);
|
||||
socket.init(expressServer);
|
||||
socket.init(expressServer, prefix);
|
||||
|
||||
/**
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
@@ -221,10 +227,10 @@ export const startServer = async (
|
||||
|
||||
expressServer.listen(serverPort, '0.0.0.0', () => {
|
||||
const nif = getNetworkInterfaces();
|
||||
consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
|
||||
consoleSuccess(`Local: http://localhost:${serverPort}${prefix}/editor`);
|
||||
for (const key in nif) {
|
||||
const address = nif[key].address;
|
||||
consoleSuccess(`Network: http://${address}:${serverPort}/editor`);
|
||||
consoleSuccess(`Network: http://${address}:${serverPort}${prefix}/editor`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -243,16 +249,6 @@ export const startIntegrations = async () => {
|
||||
// if a config is not provided, we use the persisted one
|
||||
const { osc, http } = getDataProvider().getData();
|
||||
|
||||
if (osc) {
|
||||
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
|
||||
try {
|
||||
oscIntegration.init(osc);
|
||||
integrationService.register(oscIntegration);
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (http) {
|
||||
logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...');
|
||||
try {
|
||||
@@ -262,6 +258,21 @@ export const startIntegrations = async () => {
|
||||
logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isOntimeCloud) {
|
||||
logger.info(LogOrigin.Tx, 'Skipping OSC in Cloud environment...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (osc) {
|
||||
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
|
||||
try {
|
||||
oscIntegration.init(osc);
|
||||
integrationService.register(oscIntegration);
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,14 @@ export class SimpleTimer {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public addTime(millis: number): SimpleTimerState {
|
||||
this.state.duration += millis;
|
||||
// the value of current will be overridden when update is called,
|
||||
// but if we are in pause or stop state it will not be changed so we do it here
|
||||
this.state.current += millis;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public setDirection(direction: SimpleDirection, timeNow: number): SimpleTimerState {
|
||||
// if we are playing, we need to reset the targets
|
||||
if (this.state.playback === SimplePlayback.Start) {
|
||||
|
||||
@@ -177,5 +177,60 @@ describe('SimpleTimer count-down', () => {
|
||||
playback: SimplePlayback.Start,
|
||||
});
|
||||
});
|
||||
|
||||
test('adding time affects final result', () => {
|
||||
timer.reset();
|
||||
|
||||
timer.setTime(1000);
|
||||
timer.start(0);
|
||||
timer.update(100);
|
||||
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
|
||||
|
||||
timer.addTime(1000);
|
||||
timer.update(200);
|
||||
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
|
||||
|
||||
timer.update(300);
|
||||
expect(timer.state).toMatchObject({ current: 1700, duration: 2000 });
|
||||
|
||||
timer.stop();
|
||||
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
|
||||
});
|
||||
|
||||
test('adding time affects paused timer', () => {
|
||||
timer.reset();
|
||||
|
||||
timer.setTime(1000);
|
||||
timer.start(0);
|
||||
timer.update(100);
|
||||
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
|
||||
|
||||
timer.pause(200);
|
||||
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
|
||||
|
||||
timer.addTime(1000);
|
||||
timer.update(200);
|
||||
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
|
||||
|
||||
timer.start(300);
|
||||
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
|
||||
});
|
||||
|
||||
test('adding time affects stopped timer, but returns to initial valuses when stopped again', () => {
|
||||
timer.reset();
|
||||
|
||||
timer.setTime(1000);
|
||||
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
|
||||
|
||||
timer.addTime(1000);
|
||||
expect(timer.state).toMatchObject({ current: 2000, duration: 2000 });
|
||||
|
||||
timer.start(0);
|
||||
timer.update(100);
|
||||
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
|
||||
|
||||
timer.stop();
|
||||
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { srcFiles } from './setup/index.js';
|
||||
|
||||
// =================================================
|
||||
// resolve running environment
|
||||
@@ -13,25 +14,26 @@ export const isTest = Boolean(process.env.IS_TEST);
|
||||
export const environment = isTest ? 'test' : env;
|
||||
export const isDocker = env === 'docker';
|
||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||
|
||||
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
||||
/**
|
||||
* Updates the router prefix in the index.html file
|
||||
* This is only needed in the cloud environment where the client is not at the root segment
|
||||
* ie: https://cloud.getontime.com/client-hash/timer
|
||||
*/
|
||||
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX) {
|
||||
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX): string {
|
||||
if (!prefix) {
|
||||
return;
|
||||
return '';
|
||||
}
|
||||
|
||||
const indexFile = resolve('.', 'client', 'index.html');
|
||||
try {
|
||||
const data = readFileSync(indexFile, { encoding: 'utf-8', flag: 'r' }).replace(
|
||||
/<base href="[^"]*">/g,
|
||||
`<base href="${prefix}" />`,
|
||||
const data = readFileSync(srcFiles.clientIndexHtml, { encoding: 'utf-8', flag: 'r' }).replace(
|
||||
'<base href="/" />',
|
||||
`<base href="/${prefix}/" />`,
|
||||
);
|
||||
writeFileSync(indexFile, data, { encoding: 'utf-8', flag: 'w' });
|
||||
writeFileSync(srcFiles.clientIndexHtml, data, { encoding: 'utf-8', flag: 'w' });
|
||||
} catch (_error) {
|
||||
/** unhandled */
|
||||
}
|
||||
|
||||
return `/${prefix}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SimpleDirection, SimpleTimerState } from 'ontime-types';
|
||||
import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types';
|
||||
|
||||
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
@@ -59,7 +59,11 @@ export class AuxTimerService {
|
||||
|
||||
@broadcastReturn
|
||||
addTime(millis: number) {
|
||||
return this.timer.setTime(this.timer.state.current + millis);
|
||||
if (this.timer.state.playback === SimplePlayback.Start) {
|
||||
this.timer.addTime(millis);
|
||||
return this.timer.update(this.getTime());
|
||||
}
|
||||
return this.timer.addTime(millis);
|
||||
}
|
||||
|
||||
@broadcastReturn
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { copyFile, rename } from 'fs/promises';
|
||||
import { copyFile } from 'fs/promises';
|
||||
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { publicDir } from '../../setup/index.js';
|
||||
import {
|
||||
appendToName,
|
||||
dockerSafeRename,
|
||||
ensureDirectory,
|
||||
generateUniqueFileName,
|
||||
getFileNameFromPath,
|
||||
@@ -93,7 +94,7 @@ async function handleCorruptedFile(filePath: string, fileName: string): Promise<
|
||||
|
||||
// and make a new file with the recovered data
|
||||
const newPath = appendToName(filePath, '(recovered)');
|
||||
await rename(filePath, newPath);
|
||||
await dockerSafeRename(filePath, newPath);
|
||||
return getFileNameFromPath(newPath);
|
||||
}
|
||||
|
||||
@@ -231,7 +232,7 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
|
||||
}
|
||||
|
||||
const pathToRenamed = getPathToProject(newFilename);
|
||||
await rename(projectFilePath, pathToRenamed);
|
||||
await dockerSafeRename(projectFilePath, pathToRenamed);
|
||||
|
||||
// Update the last loaded project config if current loaded project is the one being renamed
|
||||
const isLoaded = await isLastLoadedProject(originalFile);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { copyFile, readFile, rename, stat } from 'fs/promises';
|
||||
import { copyFile, readFile, stat } from 'fs/promises';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
import { publicDir } from '../../setup/index.js';
|
||||
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
|
||||
import {
|
||||
dockerSafeRename,
|
||||
ensureDirectory,
|
||||
getFilesFromFolder,
|
||||
removeFileExtension,
|
||||
} from '../../utils/fileManagement.js';
|
||||
|
||||
/**
|
||||
* Handles the upload of a new project file
|
||||
@@ -14,13 +19,13 @@ import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../
|
||||
*/
|
||||
export async function handleUploaded(filePath: string, name: string) {
|
||||
const newFilePath = join(publicDir.projectsDir, name);
|
||||
await rename(filePath, newFilePath);
|
||||
await dockerSafeRename(filePath, newFilePath);
|
||||
}
|
||||
|
||||
export async function handleImageUpload(filePath: string, name: string): Promise<string> {
|
||||
ensureDirectory(publicDir.logoDir);
|
||||
const newFilePath = join(publicDir.logoDir, name);
|
||||
await rename(filePath, newFilePath);
|
||||
await dockerSafeRename(filePath, newFilePath);
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -86,7 +91,7 @@ export async function copyCorruptFile(filePath: string, name: string): Promise<v
|
||||
*/
|
||||
export async function moveCorruptFile(filePath: string, name: string): Promise<void> {
|
||||
const newPath = join(publicDir.corruptDir, name);
|
||||
return rename(filePath, newPath);
|
||||
return dockerSafeRename(filePath, newPath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,8 @@ export const srcDir = {
|
||||
} as const;
|
||||
|
||||
export const srcFiles = {
|
||||
/** Path to start index.html */
|
||||
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
|
||||
/** Path to bundled CSS */
|
||||
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
|
||||
/** Path to bundled external readme */
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cleanURL } from '../url.js';
|
||||
|
||||
describe('url is correctly formatted', () => {
|
||||
it('has no leading spaces', () => {
|
||||
const test = ' http://testing';
|
||||
const expected = 'http://testing';
|
||||
expect(cleanURL(test)).toBe(expected);
|
||||
});
|
||||
|
||||
it('has no trailing spaces', () => {
|
||||
const test = 'http://testing ';
|
||||
const expected = 'http://testing';
|
||||
expect(cleanURL(test)).toBe(expected);
|
||||
});
|
||||
|
||||
it('doesnt contain spaces', () => {
|
||||
const test = 'http://t e s t i n g';
|
||||
const expected = 'http://t%20e%20s%20t%20i%20n%20g';
|
||||
expect(cleanURL(test)).toBe(expected);
|
||||
});
|
||||
|
||||
it('only contains allowed characters', () => {
|
||||
const test = 'http://<>[]{}|^';
|
||||
const expected = 'http://';
|
||||
expect(cleanURL(test)).toBe(expected);
|
||||
});
|
||||
|
||||
it('begins with http://', () => {
|
||||
const test = 'ontime.com';
|
||||
const expected = 'http://ontime.com';
|
||||
expect(cleanURL(test)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { readdir, copyFile } from 'fs/promises';
|
||||
import { existsSync, mkdirSync, PathLike } from 'fs';
|
||||
import { readdir, copyFile, unlink } from 'fs/promises';
|
||||
import { basename, extname, join, parse } from 'path';
|
||||
|
||||
/**
|
||||
@@ -105,3 +105,12 @@ export async function copyDirectory(src: string, dest: string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* workaround avoids origin errors in docker deployments
|
||||
* EXDEV cross-device link not permitted
|
||||
*/
|
||||
export async function dockerSafeRename(oldPath: PathLike, newPath: PathLike) {
|
||||
await copyFile(oldPath, newPath);
|
||||
await unlink(oldPath);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* @description Cleans given url
|
||||
* @param {string} url - URL to be checked
|
||||
* @returns {string} Sanitized url
|
||||
*/
|
||||
export const cleanURL = (url: string): string => {
|
||||
// trim whitespaces
|
||||
let sanitised = url.trim();
|
||||
|
||||
// clear any whitespaces
|
||||
sanitised = sanitised.split(' ').join('%20');
|
||||
|
||||
// contain only allowed characters
|
||||
sanitised = sanitised.replace(/([@\s<>[\]{}|\\^])+/g, '');
|
||||
|
||||
// starts with http://
|
||||
if (!sanitised.startsWith('http://')) sanitised = `http://${sanitised}`;
|
||||
|
||||
return sanitised;
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
ontime:
|
||||
container_name: ontime
|
||||
|
||||
@@ -4,6 +4,10 @@ const fileToUpload = 'e2e/tests/fixtures/test-db.json';
|
||||
|
||||
test('project file upload', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
// close the welcome modal if it is open
|
||||
await page.keyboard.down('Escape');
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.5",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
Reference in New Issue
Block a user