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:
Alex Christoffer Rasmussen
2024-06-15 12:26:41 +02:00
committed by GitHub
parent 70e8fbf327
commit a15eb0db4c
33 changed files with 868 additions and 169 deletions
+6 -3
View File
@@ -4,16 +4,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
const preferredClientName = socketClientName.getState().name;
connectSocket(preferredClientName);
connectSocket();
function App() {
return (
@@ -24,11 +23,15 @@ function App() {
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
<ErrorBoundary>
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
+4
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
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'));
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<Suspense fallback={null}>
<Routes>
+1
View File
@@ -11,6 +11,7 @@ export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
// resolve 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 useClickOutside from '../../hooks/useClickOutside';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
@@ -34,8 +34,10 @@ interface NavigationMenuProps {
function NavigationMenu(props: NavigationMenuProps) {
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 { toggleMirror } = useViewOptionsStore();
@@ -45,7 +47,7 @@ function NavigationMenu(props: NavigationMenuProps) {
return createPortal(
<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'>
<DrawerOverlay />
<DrawerContent>
@@ -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 { 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 = () => {
const featureSelector = (state: RuntimeStore) => ({
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);
+56 -5
View File
@@ -1,21 +1,31 @@
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 { socketClientName } from '../stores/connectionName';
import {
getClientId,
getClientName,
setClientId,
setClientName,
setClientRedirect,
setClients,
} from '../stores/clientStore';
import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false;
export let reconnectAttempts = 0;
export const connectSocket = (preferredClientName?: string) => {
export const connectSocket = () => {
websocket = new WebSocket(websocketUrl);
const preferredClientName = getClientName();
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
hasConnected = true;
@@ -24,6 +34,9 @@ export const connectSocket = (preferredClientName?: string) => {
if (preferredClientName) {
socketSendJson('set-client-name', preferredClientName);
}
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
};
websocket.onclose = () => {
@@ -54,10 +67,48 @@ export const connectSocket = (preferredClientName?: string) => {
}
switch (type) {
case 'client-name': {
socketClientName.getState().setName(payload);
case 'client-id': {
if (typeof payload === 'string') {
setClientId(payload);
}
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': {
addLog(payload as Log);
break;
@@ -3,10 +3,11 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
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 ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
@@ -30,8 +31,9 @@ export default function AppSettings() {
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />}
{panel === 'log' && <LogPanel />}
{panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
</ErrorBoundary>
@@ -73,12 +73,12 @@ $inner-padding: 1rem;
top: 0;
z-index: 3;
box-shadow: 0 1px $white-10;
background-color: $gray-1350;
}
th {
font-weight: 400;
color: $gray-400;
background-color: $gray-1350;
white-space: nowrap;
text-transform: capitalize;
}
@@ -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;
}
}
@@ -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 />
</>
);
}
@@ -6,7 +6,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log';
import * as Panel from '../PanelUtils';
import style from './LogExport.module.scss';
import style from './NetworkLogExport.module.scss';
export default function LogExport() {
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: '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',
label: 'About',
-1
View File
@@ -37,7 +37,6 @@ $bg-container-onlight: $gray-100;
$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-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;
$modal-note-color: $gray-700;
+114 -37
View File
@@ -14,7 +14,7 @@
* 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 type { Server } from 'http';
@@ -24,6 +24,7 @@ import { IAdapter } from './IAdapter.js';
import { eventStore } from '../stores/EventStore.js';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { generateId } from 'ontime-utils';
let instance: SocketServer | null = null;
@@ -31,7 +32,7 @@ export class SocketServer implements IAdapter {
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
private wss: WebSocketServer | null;
private readonly clientIds: Set<string>;
private readonly clients: Map<string, Client>;
constructor() {
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
instance = this;
this.clientIds = new Set<string>();
this.clients = new Map<string, Client>();
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.on('connection', (ws) => {
let clientId = getRandomName();
this.clientIds.add(clientId);
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with new: ${clientId}`);
const clientId = generateId();
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
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('close', () => {
this.clientIds.delete(clientId);
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with disconnected: ${clientId}`);
this.clients.delete(clientId);
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientId}`);
this.sendClientList();
});
ws.on('message', (data) => {
@@ -84,26 +102,44 @@ export class SocketServer implements IAdapter {
ws.send(
JSON.stringify({
type: 'client-name',
payload: clientId,
payload: this.clients.get(clientId).name,
}),
);
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 (payload) {
const previousName = clientId;
clientId = payload;
this.clientIds.delete(previousName);
this.clientIds.add(clientId);
logger.info(LogOrigin.Client, `Client ${previousName} renamed to ${clientId}`);
const previousData = this.clients.get(clientId);
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`);
this.clients.set(clientId, { ...previousData, name: payload });
ws.send(
JSON.stringify({
type: 'client-name',
payload: this.clients.get(clientId).name,
}),
);
}
ws.send(
JSON.stringify({
type: 'client-name',
payload: clientId,
}),
);
this.sendClientList();
return;
}
@@ -135,17 +171,58 @@ export class SocketServer implements IAdapter {
});
}
// message is any serializable value
sendAsJson(message: unknown) {
this.wss?.clients.forEach((client) => {
try {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
} catch (_) {
/** We do not handle this error */
}
private sendClientList(): void {
const payload = Object.fromEntries(this.clients.entries());
this.sendAsJson({ type: 'client-list', payload });
}
public getClientList(): string[] {
return Array.from(this.clients.keys());
}
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() {
@@ -10,6 +10,7 @@ import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty, updateEvent } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.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');
},
/* 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>;
+1
View File
@@ -66,6 +66,7 @@ export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.ty
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
// CLIENT
export type { Client, ClientList, ClientType } from './definitions/Clients.type.js';
// TYPE UTILITIES
export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js';