mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +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 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
-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 { 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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+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 * 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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user