mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-23 16:09:11 +00:00
Client remote control (#930)
--------- Co-authored-by: kellhogs <fabianpos99@gmail.com> Co-authored-by: Carlos Valente <carlosvalente@pm.me> Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
70e8fbf327
commit
a15eb0db4c
@@ -4,16 +4,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||||
|
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||||
import { AppContextProvider } from './common/context/AppContext';
|
import { AppContextProvider } from './common/context/AppContext';
|
||||||
import { ontimeQueryClient } from './common/queryClient';
|
import { ontimeQueryClient } from './common/queryClient';
|
||||||
import { socketClientName } from './common/stores/connectionName';
|
|
||||||
import { connectSocket } from './common/utils/socket';
|
import { connectSocket } from './common/utils/socket';
|
||||||
import theme from './theme/theme';
|
import theme from './theme/theme';
|
||||||
import { TranslationProvider } from './translation/TranslationProvider';
|
import { TranslationProvider } from './translation/TranslationProvider';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
|
|
||||||
const preferredClientName = socketClientName.getState().name;
|
connectSocket();
|
||||||
connectSocket(preferredClientName);
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -24,11 +23,15 @@ function App() {
|
|||||||
<div className='App'>
|
<div className='App'>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
|
<IdentifyOverlay />
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</div>
|
</div>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div id='identify-portal' />
|
||||||
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppContextProvider>
|
</AppContextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useClientPath } from './common/hooks/useClientPath';
|
||||||
import Log from './features/log/Log';
|
import Log from './features/log/Log';
|
||||||
import withPreset from './features/PresetWrapper';
|
import withPreset from './features/PresetWrapper';
|
||||||
import withData from './features/viewers/ViewWrapper';
|
import withData from './features/viewers/ViewWrapper';
|
||||||
@@ -34,6 +35,9 @@ const TimerControl = lazy(() => import('./features/control/playback/TimerControl
|
|||||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||||
|
|
||||||
export default function AppRouter() {
|
export default function AppRouter() {
|
||||||
|
// handle client path changes
|
||||||
|
useClientPath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const RUNTIME = ['runtimeStore'];
|
|||||||
export const SHEET_STATE = ['sheetState'];
|
export const SHEET_STATE = ['sheetState'];
|
||||||
export const URL_PRESETS = ['urlpresets'];
|
export const URL_PRESETS = ['urlpresets'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
|
||||||
// resolve location
|
// resolve location
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.buttonSection {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $element-spacing;
|
||||||
|
margin-top: -$element-spacing;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftAddon,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './ClientModal.module.scss';
|
||||||
|
|
||||||
|
interface RedirectClientModalProps {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||||
|
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
||||||
|
const [path, setPath] = useState(currentPath);
|
||||||
|
|
||||||
|
const { setRedirect } = setClientRemote;
|
||||||
|
|
||||||
|
const handleRedirect = () => {
|
||||||
|
if (path !== currentPath && path !== '') {
|
||||||
|
setRedirect({ target: id, redirect: path });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const host = `${window.location.origin}/`;
|
||||||
|
const canSubmit = path !== currentPath && path !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<InputGroup variant='ontime-filled' size='md'>
|
||||||
|
<InputLeftAddon>{host}</InputLeftAddon>
|
||||||
|
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||||
|
</InputGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={style.buttonSection}>
|
||||||
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './ClientModal.module.scss';
|
||||||
|
|
||||||
|
interface RenameClientModalProps {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameClientModal(props: RenameClientModalProps) {
|
||||||
|
const { id, name: currentName = '', isOpen, onClose } = props;
|
||||||
|
const [name, setName] = useState(currentName);
|
||||||
|
|
||||||
|
const { setClientName } = setClientRemote;
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (name !== currentName && name !== '') {
|
||||||
|
setClientName({ target: id, rename: name });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = name !== currentName && name !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Rename: {currentName}</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
placeholder='new name'
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={style.buttonSection}>
|
||||||
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
border: 10px solid $ui-white;
|
||||||
|
background-color: $ui-black;
|
||||||
|
|
||||||
|
color: $ui-white;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 100;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: -10vh;
|
||||||
|
line-height: 1em;
|
||||||
|
font-size: 12vw;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
color: $label-gray;
|
||||||
|
line-height: 2em;
|
||||||
|
font-size: 3vw;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
|
|
||||||
|
import style from './IdentifyOverlay.module.scss';
|
||||||
|
|
||||||
|
export default function IdentifyOverlay() {
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const showOverlay = clients[id]?.identify;
|
||||||
|
|
||||||
|
if (!showOverlay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalRoot = document.getElementById('identify-portal');
|
||||||
|
|
||||||
|
if (!portalRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createPortal(<Overlay />, portalRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Overlay() {
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const name = useClientStore((store) => store.name);
|
||||||
|
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const { setIdentify } = setClientRemote;
|
||||||
|
const showOverlay = clients[id]?.identify;
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIdentify({ target: id, identify: false });
|
||||||
|
}, [id, setIdentify]);
|
||||||
|
|
||||||
|
// start a timer that will close the overlay after some time
|
||||||
|
useEffect(() => {
|
||||||
|
if (showOverlay) {
|
||||||
|
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showOverlay, id, setIdentify, handleClose]);
|
||||||
|
|
||||||
|
console.log('here2');
|
||||||
|
return (
|
||||||
|
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
|
||||||
|
<div className={style.name}>{name}</div>
|
||||||
|
<div className={style.message}>Click to close</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,10 +19,10 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
|||||||
|
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import { isKeyEnter } from '../../utils/keyEvent';
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -34,8 +34,10 @@ interface NavigationMenuProps {
|
|||||||
function NavigationMenu(props: NavigationMenuProps) {
|
function NavigationMenu(props: NavigationMenuProps) {
|
||||||
const { isOpen, onClose } = props;
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
|
const id = useClientStore((store) => store.id);
|
||||||
|
const name = useClientStore((store) => store.name);
|
||||||
|
|
||||||
|
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
const { toggleMirror } = useViewOptionsStore();
|
const { toggleMirror } = useViewOptionsStore();
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div id='navigation-menu-portal' ref={menuRef}>
|
<div id='navigation-menu-portal' ref={menuRef}>
|
||||||
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
|
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
|
|||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalOverlay,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { setClientName } from '../../../hooks/useSocket';
|
|
||||||
import { useSocketClientName } from '../../../stores/connectionName';
|
|
||||||
|
|
||||||
interface RenameClientModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
|
|
||||||
const { name: clientName, persistName } = useSocketClientName();
|
|
||||||
const [newName, setNewName] = useState(clientName);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNewName(clientName);
|
|
||||||
}, [isOpen, clientName]);
|
|
||||||
|
|
||||||
const handleRename = async () => {
|
|
||||||
if (newName) {
|
|
||||||
setClientName(newName);
|
|
||||||
persistName(newName);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
size='sm'
|
|
||||||
closeOnOverlayClick={false}
|
|
||||||
motionPreset='slideInBottom'
|
|
||||||
scrollBehavior='inside'
|
|
||||||
preserveScrollBarGap
|
|
||||||
variant='ontime'
|
|
||||||
>
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>Rename client</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<ModalBody>
|
|
||||||
<Input
|
|
||||||
placeholder='Connection must have a name'
|
|
||||||
defaultValue={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
variant='ontime-filled'
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
isDisabled={newName === clientName || !newName}
|
|
||||||
onClick={handleRename}
|
|
||||||
width='100%'
|
|
||||||
variant='ontime-filled'
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useClientStore } from '../stores/clientStore';
|
||||||
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
export const useClientPath = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
const redirect = useClientStore((store) => store.redirect);
|
||||||
|
const setRedirect = useClientStore((store) => store.setRedirect);
|
||||||
|
|
||||||
|
// notify of client path changes
|
||||||
|
useEffect(() => {
|
||||||
|
//remove leading '/' from path
|
||||||
|
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
|
||||||
|
socketSendJson('set-client-path', fullPath);
|
||||||
|
}, [pathname, search]);
|
||||||
|
|
||||||
|
// navigate to new path when received from server
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirect === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear redirect
|
||||||
|
setRedirect('');
|
||||||
|
|
||||||
|
// navigate if there is a path change
|
||||||
|
if (redirect !== pathname + search) {
|
||||||
|
navigate(redirect);
|
||||||
|
}
|
||||||
|
}, [navigate, pathname, redirect, search, setRedirect]);
|
||||||
|
};
|
||||||
@@ -3,6 +3,12 @@ import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
|||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
export const setClientRemote = {
|
||||||
|
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
||||||
|
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
||||||
|
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
||||||
|
};
|
||||||
|
|
||||||
export const useRundownEditor = () => {
|
export const useRundownEditor = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { ClientList } from 'ontime-types';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface ClientStore {
|
||||||
|
name?: string;
|
||||||
|
setName: (newValue: string) => void;
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
setId: (newValue: string) => void;
|
||||||
|
|
||||||
|
redirect: string;
|
||||||
|
setRedirect: (newValue: string) => void;
|
||||||
|
|
||||||
|
clients: ClientList;
|
||||||
|
setClients: (clients: ClientList) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientNameKey = 'ontime-client-name';
|
||||||
|
|
||||||
|
function persistNameInStorage(newValue: string) {
|
||||||
|
localStorage.setItem(clientNameKey, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClientStore = create<ClientStore>((set) => ({
|
||||||
|
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||||
|
setName: (name: string) =>
|
||||||
|
set(() => {
|
||||||
|
persistNameInStorage(name);
|
||||||
|
return { name };
|
||||||
|
}),
|
||||||
|
|
||||||
|
id: '',
|
||||||
|
setId: (id) => set({ id }),
|
||||||
|
|
||||||
|
redirect: '',
|
||||||
|
setRedirect: (redirect: string) => set({ redirect }),
|
||||||
|
|
||||||
|
clients: {},
|
||||||
|
setClients: (clients: ClientList) => set({ clients }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows getting client name (outside react)
|
||||||
|
*/
|
||||||
|
export function getClientName(): string | undefined {
|
||||||
|
return useClientStore.getState().name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating current client name (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientName(name: string): void {
|
||||||
|
useClientStore.getState().setName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating current client name (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientId(id: string): void {
|
||||||
|
useClientStore.getState().setId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows getting client name (outside react)
|
||||||
|
*/
|
||||||
|
export function getClientId(): string | undefined {
|
||||||
|
return useClientStore.getState().id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating redirect (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientRedirect(path: string): void {
|
||||||
|
useClientStore.getState().setRedirect(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows setting clients (outside react)
|
||||||
|
*/
|
||||||
|
export function setClients(clients: ClientList): void {
|
||||||
|
return useClientStore.getState().setClients(clients);
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useStore } from 'zustand';
|
|
||||||
import { createStore } from 'zustand/vanilla';
|
|
||||||
|
|
||||||
interface SocketClientNameState {
|
|
||||||
name?: string;
|
|
||||||
setName: (newValue: string) => void;
|
|
||||||
persistName: (newValue: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientNameKey = 'ontime-client-name';
|
|
||||||
|
|
||||||
function persistKeyToStorage(newValue: string) {
|
|
||||||
localStorage.setItem(clientNameKey, newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const socketClientName = createStore<SocketClientNameState>((set) => ({
|
|
||||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
|
||||||
setName: (newValue: string) => set(() => ({ name: newValue })),
|
|
||||||
persistName: (newValue: string) =>
|
|
||||||
set(() => {
|
|
||||||
persistKeyToStorage(newValue);
|
|
||||||
return { name: newValue };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useSocketClientName = () => useStore(socketClientName);
|
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
import { Log, RuntimeStore } from 'ontime-types';
|
import { Log, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { isProduction, RUNTIME, websocketUrl } from '../api/constants';
|
import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import { socketClientName } from '../stores/connectionName';
|
import {
|
||||||
|
getClientId,
|
||||||
|
getClientName,
|
||||||
|
setClientId,
|
||||||
|
setClientName,
|
||||||
|
setClientRedirect,
|
||||||
|
setClients,
|
||||||
|
} from '../stores/clientStore';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
||||||
|
|
||||||
export let websocket: WebSocket | null = null;
|
export let websocket: WebSocket | null = null;
|
||||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
const reconnectInterval = 1000;
|
const reconnectInterval = 1000;
|
||||||
|
|
||||||
export let shouldReconnect = true;
|
export let shouldReconnect = true;
|
||||||
export let hasConnected = false;
|
export let hasConnected = false;
|
||||||
export let reconnectAttempts = 0;
|
export let reconnectAttempts = 0;
|
||||||
|
|
||||||
export const connectSocket = (preferredClientName?: string) => {
|
export const connectSocket = () => {
|
||||||
websocket = new WebSocket(websocketUrl);
|
websocket = new WebSocket(websocketUrl);
|
||||||
|
|
||||||
|
const preferredClientName = getClientName();
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
||||||
hasConnected = true;
|
hasConnected = true;
|
||||||
@@ -24,6 +34,9 @@ export const connectSocket = (preferredClientName?: string) => {
|
|||||||
if (preferredClientName) {
|
if (preferredClientName) {
|
||||||
socketSendJson('set-client-name', preferredClientName);
|
socketSendJson('set-client-name', preferredClientName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
socketSendJson('set-client-type', 'ontime');
|
||||||
|
socketSendJson('set-client-path', location.pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
@@ -54,10 +67,48 @@ export const connectSocket = (preferredClientName?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'client-name': {
|
case 'client-id': {
|
||||||
socketClientName.getState().setName(payload);
|
if (typeof payload === 'string') {
|
||||||
|
setClientId(payload);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'client-name': {
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
setClientName(payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client-rename': {
|
||||||
|
if (typeof payload === 'object') {
|
||||||
|
const id = getClientId();
|
||||||
|
if (payload.target && payload.target === id) {
|
||||||
|
setClientName(payload.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client-redirect': {
|
||||||
|
if (typeof payload === 'object') {
|
||||||
|
const id = getClientId();
|
||||||
|
if (payload.target && payload.target === id) {
|
||||||
|
setClientRedirect(payload.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client-list': {
|
||||||
|
setClients(payload);
|
||||||
|
if (!isProduction) {
|
||||||
|
ontimeQueryClient.setQueryData(CLIENT_LIST, payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'ontime-log': {
|
case 'ontime-log': {
|
||||||
addLog(payload as Log);
|
addLog(payload as Log);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { ErrorBoundary } from '@sentry/react';
|
|||||||
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
||||||
|
|
||||||
import AboutPanel from './panel/about-panel/AboutPanel';
|
import AboutPanel from './panel/about-panel/AboutPanel';
|
||||||
|
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
|
||||||
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
||||||
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
||||||
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
||||||
import LogPanel from './panel/log-panel/LogPanel';
|
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
|
||||||
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
||||||
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
||||||
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
||||||
@@ -30,8 +31,9 @@ export default function AppSettings() {
|
|||||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||||
{panel === 'sources' && <SourcesPanel />}
|
{panel === 'sources' && <SourcesPanel />}
|
||||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||||
|
{panel === 'client_control' && <ClientControlPanel />}
|
||||||
{panel === 'about' && <AboutPanel />}
|
{panel === 'about' && <AboutPanel />}
|
||||||
{panel === 'log' && <LogPanel />}
|
{panel === 'network' && <NetworkLogPanel location={location} />}
|
||||||
{panel === 'shutdown' && <ShutdownPanel />}
|
{panel === 'shutdown' && <ShutdownPanel />}
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -73,12 +73,12 @@ $inner-padding: 1rem;
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
box-shadow: 0 1px $white-10;
|
box-shadow: 0 1px $white-10;
|
||||||
|
background-color: $gray-1350;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
background-color: $gray-1350;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: $element-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.halfWidth {
|
||||||
|
width: 50%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pathList {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeList {
|
||||||
|
width: 100%;
|
||||||
|
* {
|
||||||
|
margin-right: $element-spacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import ClientList from './ClientList';
|
||||||
|
|
||||||
|
export default function ClientControlPanel() {
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Manage clients</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<ClientList />
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
||||||
|
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||||
|
import { setClientRemote } from '../../../../common/hooks/useSocket';
|
||||||
|
import { useClientStore } from '../../../../common/stores/clientStore';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import style from './ClientControlPanel.module.scss';
|
||||||
|
|
||||||
|
export default function ClientList() {
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const { isOpen: isOpenRedirect, onOpen: onOpenRedirect, onClose: onCloseRedirect } = useDisclosure();
|
||||||
|
const { isOpen: isOpenRename, onOpen: onOpenRename, onClose: onCloseRename } = useDisclosure();
|
||||||
|
const { setIdentify } = setClientRemote;
|
||||||
|
|
||||||
|
const [targetId, setTargetId] = useState('');
|
||||||
|
|
||||||
|
const openRename = (targetId: string) => {
|
||||||
|
setTargetId(targetId);
|
||||||
|
onOpenRename();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRedirect = (targetId: string) => {
|
||||||
|
setTargetId(targetId);
|
||||||
|
onOpenRedirect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
||||||
|
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
||||||
|
|
||||||
|
const targetClient = clients[targetId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOpenRedirect && (
|
||||||
|
<RedirectClientModal
|
||||||
|
id={targetId}
|
||||||
|
name={targetClient?.name}
|
||||||
|
path={targetClient?.path}
|
||||||
|
isOpen={isOpenRedirect}
|
||||||
|
onClose={onCloseRedirect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isOpenRename && (
|
||||||
|
<RenameClientModal id={targetId} name={targetClient?.name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
|
)}
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
|
||||||
|
<Panel.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td className={style.fullWidth}>Client Name (Connection ID)</td>
|
||||||
|
<td className={style.fullWidth}>Path</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ontimeClients.map(([key, client]) => {
|
||||||
|
const { identify, name, path } = client;
|
||||||
|
const isCurrent = id === key;
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className={style.badgeList}>
|
||||||
|
<Badge variant='outline' size='xs'>
|
||||||
|
{key}
|
||||||
|
</Badge>
|
||||||
|
{isCurrent && (
|
||||||
|
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
||||||
|
self
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
|
||||||
|
<td className={style.actionButtons}>
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
className={`${identify ? style.blink : ''}`}
|
||||||
|
isDisabled={isCurrent}
|
||||||
|
variant={identify ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||||
|
onClick={() => {
|
||||||
|
setIdentify({ target: key, identify: !identify });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Identify
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-subtle'
|
||||||
|
data-testid={isCurrent ? '' : 'not-self-rename'}
|
||||||
|
onClick={() => openRename(key)}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-subtle'
|
||||||
|
isDisabled={isCurrent}
|
||||||
|
data-testid={isCurrent ? '' : 'not-self-redirect'}
|
||||||
|
onClick={() => openRedirect(key)}
|
||||||
|
>
|
||||||
|
Redirect
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
|
||||||
|
<Panel.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
||||||
|
<td className={style.halfWidth}>Client type</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{otherClients.map(([key, client]) => {
|
||||||
|
const { name, type } = client;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className={style.badgeList}>
|
||||||
|
<Badge variant='outline' size='sx'>
|
||||||
|
{key}
|
||||||
|
</Badge>
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
<td>{type}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import * as Panel from '../PanelUtils';
|
|
||||||
|
|
||||||
import LogExport from './LogExport';
|
|
||||||
import InfoNif from './NetworkInterfaces';
|
|
||||||
|
|
||||||
export default function LogPanel() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Panel.Header>Log</Panel.Header>
|
|
||||||
<Panel.Section>
|
|
||||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
|
||||||
</Panel.Section>
|
|
||||||
<InfoNif />
|
|
||||||
<LogExport />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
|
|||||||
import Log from '../../../log/Log';
|
import Log from '../../../log/Log';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
import style from './LogExport.module.scss';
|
import style from './NetworkLogExport.module.scss';
|
||||||
|
|
||||||
export default function LogExport() {
|
export default function LogExport() {
|
||||||
const extract = (event: MouseEvent) => {
|
const extract = (event: MouseEvent) => {
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
|
import { PanelBaseProps } from '../../settingsStore';
|
||||||
|
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import InfoNif from './NetworkInterfaces';
|
||||||
|
import LogExport from './NetworkLogExport';
|
||||||
|
|
||||||
|
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||||
|
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||||
|
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Panel.Header>Network</Panel.Header>
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||||
|
</Panel.Section>
|
||||||
|
<InfoNif />
|
||||||
|
<div ref={logRef}>
|
||||||
|
<LogExport />
|
||||||
|
</div>
|
||||||
|
<div ref={clientsRef}>
|
||||||
|
<ClientControlPanel />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,21 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
|||||||
{ id: 'integrations__http', label: 'HTTP settings' },
|
{ id: 'integrations__http', label: 'HTTP settings' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'log', label: 'Log', split: true },
|
{
|
||||||
|
id: 'network',
|
||||||
|
label: 'Network',
|
||||||
|
split: true,
|
||||||
|
secondary: [
|
||||||
|
{
|
||||||
|
id: 'network__log',
|
||||||
|
label: 'Application log',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'network__clients',
|
||||||
|
label: 'Manage clients',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ $bg-container-onlight: $gray-100;
|
|||||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
$box-shadow-right: rgba(0, 0, 0, 0.15) 3px 0 3px 0;
|
$box-shadow-right: rgba(0, 0, 0, 0.15) 3px 0 3px 0;
|
||||||
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
|
||||||
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
|
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
|
||||||
|
|
||||||
$modal-note-color: $gray-700;
|
$modal-note-color: $gray-700;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* Payload: adds necessary payload for the request to be completed
|
* Payload: adds necessary payload for the request to be completed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LogOrigin } from 'ontime-types';
|
import { Client, LogOrigin } from 'ontime-types';
|
||||||
|
|
||||||
import { WebSocket, WebSocketServer } from 'ws';
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
@@ -24,6 +24,7 @@ import { IAdapter } from './IAdapter.js';
|
|||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||||
|
import { generateId } from 'ontime-utils';
|
||||||
|
|
||||||
let instance: SocketServer | null = null;
|
let instance: SocketServer | null = null;
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export class SocketServer implements IAdapter {
|
|||||||
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
|
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
|
||||||
|
|
||||||
private wss: WebSocketServer | null;
|
private wss: WebSocketServer | null;
|
||||||
private readonly clientIds: Set<string>;
|
private readonly clients: Map<string, Client>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
@@ -40,7 +41,7 @@ export class SocketServer implements IAdapter {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
||||||
instance = this;
|
instance = this;
|
||||||
this.clientIds = new Set<string>();
|
this.clients = new Map<string, Client>();
|
||||||
this.wss = null;
|
this.wss = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +49,32 @@ export class SocketServer implements IAdapter {
|
|||||||
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
|
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
|
||||||
|
|
||||||
this.wss.on('connection', (ws) => {
|
this.wss.on('connection', (ws) => {
|
||||||
let clientId = getRandomName();
|
const clientId = generateId();
|
||||||
this.clientIds.add(clientId);
|
|
||||||
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with new: ${clientId}`);
|
this.clients.set(clientId, {
|
||||||
|
type: 'unknown',
|
||||||
|
identify: false,
|
||||||
|
name: getRandomName(),
|
||||||
|
path: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`);
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'client-id',
|
||||||
|
payload: clientId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'client-name',
|
||||||
|
payload: this.clients.get(clientId).name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sendClientList();
|
||||||
|
|
||||||
// send store payload on connect
|
// send store payload on connect
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -60,18 +84,12 @@ export class SocketServer implements IAdapter {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'client-name',
|
|
||||||
payload: clientId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
ws.on('error', console.error);
|
ws.on('error', console.error);
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
this.clientIds.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with disconnected: ${clientId}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientId}`);
|
||||||
|
this.sendClientList();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
@@ -84,26 +102,44 @@ export class SocketServer implements IAdapter {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'client-name',
|
type: 'client-name',
|
||||||
payload: clientId,
|
payload: this.clients.get(clientId).name,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'set-client-type') {
|
||||||
|
if (payload && typeof payload == 'string') {
|
||||||
|
const previousData = this.clients.get(clientId);
|
||||||
|
this.clients.set(clientId, { ...previousData, type: payload });
|
||||||
|
}
|
||||||
|
this.sendClientList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'set-client-path') {
|
||||||
|
if (payload && typeof payload == 'string') {
|
||||||
|
const previousData = this.clients.get(clientId);
|
||||||
|
previousData.path = payload;
|
||||||
|
this.clients.set(clientId, previousData);
|
||||||
|
}
|
||||||
|
this.sendClientList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'set-client-name') {
|
if (type === 'set-client-name') {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const previousName = clientId;
|
const previousData = this.clients.get(clientId);
|
||||||
clientId = payload;
|
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`);
|
||||||
this.clientIds.delete(previousName);
|
this.clients.set(clientId, { ...previousData, name: payload });
|
||||||
this.clientIds.add(clientId);
|
ws.send(
|
||||||
logger.info(LogOrigin.Client, `Client ${previousName} renamed to ${clientId}`);
|
JSON.stringify({
|
||||||
|
type: 'client-name',
|
||||||
|
payload: this.clients.get(clientId).name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ws.send(
|
this.sendClientList();
|
||||||
JSON.stringify({
|
|
||||||
type: 'client-name',
|
|
||||||
payload: clientId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,17 +171,58 @@ export class SocketServer implements IAdapter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// message is any serializable value
|
private sendClientList(): void {
|
||||||
sendAsJson(message: unknown) {
|
const payload = Object.fromEntries(this.clients.entries());
|
||||||
this.wss?.clients.forEach((client) => {
|
this.sendAsJson({ type: 'client-list', payload });
|
||||||
try {
|
}
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
|
||||||
client.send(JSON.stringify(message));
|
public getClientList(): string[] {
|
||||||
}
|
return Array.from(this.clients.keys());
|
||||||
} catch (_) {
|
}
|
||||||
/** We do not handle this error */
|
|
||||||
}
|
public renameClient(target: string, name: string) {
|
||||||
|
const previousData = this.clients.get(target);
|
||||||
|
if (!previousData) {
|
||||||
|
throw new Error(`Client "${target}" not found`);
|
||||||
|
}
|
||||||
|
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${name}`);
|
||||||
|
this.clients.set(target, { ...previousData, name });
|
||||||
|
this.sendAsJson({
|
||||||
|
type: 'client-rename',
|
||||||
|
payload: { name, target },
|
||||||
});
|
});
|
||||||
|
this.sendClientList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public redirectClient(target: string, path: string) {
|
||||||
|
const previousData = this.clients.get(target);
|
||||||
|
if (!previousData) {
|
||||||
|
throw new Error(`Client "${target}" not found`);
|
||||||
|
}
|
||||||
|
this.sendAsJson({ type: 'client-redirect', payload: { target, path } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public identifyClient(target: string, identify: boolean) {
|
||||||
|
const previousData = this.clients.get(target);
|
||||||
|
if (!previousData) {
|
||||||
|
throw new Error(`Client "${target}" not found`);
|
||||||
|
}
|
||||||
|
this.clients.set(target, { ...previousData, identify });
|
||||||
|
this.sendClientList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// message is any serializable value
|
||||||
|
public sendAsJson(message: unknown) {
|
||||||
|
try {
|
||||||
|
const stringifiedMessage = JSON.stringify(message);
|
||||||
|
this.wss?.clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(stringifiedMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
/** We do not handle this error */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { eventStore } from '../stores/EventStore.js';
|
|||||||
import * as assert from '../utils/assert.js';
|
import * as assert from '../utils/assert.js';
|
||||||
import { isEmptyObject } from '../utils/parserUtils.js';
|
import { isEmptyObject } from '../utils/parserUtils.js';
|
||||||
import { parseProperty, updateEvent } from './integration.utils.js';
|
import { parseProperty, updateEvent } from './integration.utils.js';
|
||||||
|
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||||
import { throttle } from '../utils/throttle.js';
|
import { throttle } from '../utils/throttle.js';
|
||||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||||
|
|
||||||
@@ -240,6 +241,33 @@ const actionHandlers: Record<string, ActionHandler> = {
|
|||||||
}
|
}
|
||||||
throw new Error('No matching method provided');
|
throw new Error('No matching method provided');
|
||||||
},
|
},
|
||||||
|
/* Client */
|
||||||
|
client: (payload) => {
|
||||||
|
assert.isObject(payload);
|
||||||
|
if (!('target' in payload) || typeof payload.target != 'string') {
|
||||||
|
throw new Error('No or invalid client target');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('rename' in payload && typeof payload.rename == 'string') {
|
||||||
|
const { target, rename } = payload;
|
||||||
|
socket.renameClient(target, rename);
|
||||||
|
return { payload: 'success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('redirect' in payload && typeof payload.redirect == 'string') {
|
||||||
|
const { target, redirect } = payload;
|
||||||
|
socket.redirectClient(target, redirect);
|
||||||
|
return { payload: 'success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('identify' in payload && typeof payload.identify == 'boolean') {
|
||||||
|
const { target, identify } = payload;
|
||||||
|
socket.identifyClient(target, identify);
|
||||||
|
return { payload: 'success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No matching method provided');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('redirect', async ({ context }) => {
|
||||||
|
const controllerPage = await context.newPage();
|
||||||
|
const remotePage = await context.newPage();
|
||||||
|
|
||||||
|
await controllerPage.goto('http://localhost:4001/editor?settings=network__clients');
|
||||||
|
await remotePage.goto('http://localhost:4001/timer');
|
||||||
|
|
||||||
|
await controllerPage.getByTestId('not-self-redirect').click();
|
||||||
|
await controllerPage.getByPlaceholder('minimal?key=0000ffff').click();
|
||||||
|
await controllerPage.getByPlaceholder('minimal?key=0000ffff').fill('clock');
|
||||||
|
await controllerPage.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
await expect(remotePage.getByTestId('clock-view')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identify', async ({ context }) => {
|
||||||
|
const controllerPage = await context.newPage();
|
||||||
|
const remotePage = await context.newPage();
|
||||||
|
|
||||||
|
await controllerPage.goto('http://localhost:4001/editor?settings=network__clients');
|
||||||
|
await remotePage.goto('http://localhost:4001/timer');
|
||||||
|
|
||||||
|
await controllerPage.getByTestId('not-self-identify').click();
|
||||||
|
|
||||||
|
await expect(remotePage.getByTestId('identify-overlay')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rename', async ({ context }) => {
|
||||||
|
const controllerPage = await context.newPage();
|
||||||
|
const remotePage = await context.newPage();
|
||||||
|
|
||||||
|
await controllerPage.goto('http://localhost:4001/editor?settings=network__clients');
|
||||||
|
await remotePage.goto('http://localhost:4001/timer');
|
||||||
|
|
||||||
|
await controllerPage.getByTestId('not-self-rename').click();
|
||||||
|
await controllerPage.getByPlaceholder('new name').click();
|
||||||
|
await controllerPage.getByPlaceholder('new name').fill('test');
|
||||||
|
await controllerPage.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
await controllerPage.getByTestId('not-self-identify').click();
|
||||||
|
|
||||||
|
await expect(remotePage.getByTestId('identify-overlay')).toContainText('test');
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type ClientType = 'unknown' | 'ontime' | string;
|
||||||
|
|
||||||
|
export type Client = {
|
||||||
|
name: string;
|
||||||
|
type: ClientType;
|
||||||
|
identify: boolean;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientList = Record<string, Client>;
|
||||||
@@ -66,6 +66,7 @@ export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.ty
|
|||||||
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
||||||
|
|
||||||
// CLIENT
|
// CLIENT
|
||||||
|
export type { Client, ClientList, ClientType } from './definitions/Clients.type.js';
|
||||||
|
|
||||||
// TYPE UTILITIES
|
// TYPE UTILITIES
|
||||||
export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js';
|
export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user