mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-11 00:59:35 +00:00
fix(network): correct defects in client management UI
- self row highlight lost a specificity fight with the table zebra striping, so the current client was only highlighted on odd rows. Reuse the data attribute pattern already used for data-warn - drop reference to a `blink` class which was never defined, the button variant already communicates the identify state - use `th` in table headers so the shared table header styling applies, matching the other settings panels - add empty states to both client tables - keep a stable row order (self first, then by name) so rows do not move as clients connect and disconnect - the redirect dialog disabled its view select when no URL presets existed, making it impossible to redirect to a plain view. Its default value also disagreed with component state, leaving the button disabled until the visible value was re-picked - rename dialog now autofocuses and submits on Enter - move the cloud ping into a card, matching the panel conventions, and flag high latency values Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
This commit is contained in:
@@ -13,6 +13,9 @@ import Select from '../select/Select';
|
|||||||
|
|
||||||
import style from './RedirectClientModal.module.scss';
|
import style from './RedirectClientModal.module.scss';
|
||||||
|
|
||||||
|
/** the first navigator view, used as the initial value of the view select */
|
||||||
|
const defaultView = `/${navigatorConstants[0].url}`;
|
||||||
|
|
||||||
interface RedirectClientModalProps {
|
interface RedirectClientModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
|
|||||||
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
|
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
|
||||||
const { data } = useUrlPresets();
|
const { data } = useUrlPresets();
|
||||||
const [path, setPath] = useState(currentPath);
|
const [path, setPath] = useState(currentPath);
|
||||||
const [selected, setSelected] = useState('/');
|
const [selected, setSelected] = useState(defaultView);
|
||||||
|
|
||||||
const { setRedirect } = setClientRemote;
|
const { setRedirect } = setClientRemote;
|
||||||
|
|
||||||
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
<Select
|
<Select
|
||||||
fluid
|
fluid
|
||||||
options={viewOptions}
|
options={viewOptions}
|
||||||
defaultValue={viewOptions[0].value}
|
defaultValue={defaultView}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
setSelected(value);
|
setSelected(value);
|
||||||
}}
|
}}
|
||||||
disabled={enabledPresets.length === 0}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
aria-label='Redirect to preset'
|
aria-label='Redirect to preset'
|
||||||
className={style.redirect}
|
className={style.redirect}
|
||||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
disabled={selected === currentPath}
|
||||||
onClick={() => handleRedirect(selected)}
|
onClick={() => handleRedirect(selected)}
|
||||||
>
|
>
|
||||||
Redirect <IoArrowForward />
|
Redirect <IoArrowForward />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
import Button from '../../../common/components/buttons/Button';
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
import Dialog from '../dialog/Dialog';
|
import Dialog from '../dialog/Dialog';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
|
|
||||||
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
|
|||||||
showCloseButton
|
showCloseButton
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
bodyElements={
|
bodyElements={
|
||||||
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
|
<Input
|
||||||
|
height='large'
|
||||||
|
placeholder='New name'
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (isKeyEnter(event) && canSubmit) {
|
||||||
|
handleRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
footerElements={
|
footerElements={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
|
|||||||
td[data-warn='true'] {
|
td[data-warn='true'] {
|
||||||
background-color: $orange-1300;
|
background-color: $orange-1300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tr[data-highlight='true'] {
|
||||||
|
background-color: $blue-1100;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.listGroup {
|
.listGroup {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MessageTag } from 'ontime-types';
|
import { MessageTag } from 'ontime-types';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { usePing } from '../../../../common/hooks/useSocket';
|
import { usePing } from '../../../../common/hooks/useSocket';
|
||||||
import { sendSocket } from '../../../../common/utils/socket';
|
import { sendSocket } from '../../../../common/utils/socket';
|
||||||
@@ -10,6 +11,9 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
|||||||
import ClientControlPanel from './client-control/ClientControlPanel';
|
import ClientControlPanel from './client-control/ClientControlPanel';
|
||||||
import LogExport from './NetworkLogExport';
|
import LogExport from './NetworkLogExport';
|
||||||
|
|
||||||
|
/** ping values above this are flagged to the user (ms) */
|
||||||
|
const slowPingThreshold = 100;
|
||||||
|
|
||||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||||
@@ -17,11 +21,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Network</Panel.Header>
|
<Panel.Header>Network</Panel.Header>
|
||||||
{isDocker && (
|
{isDocker && <OntimeCloudStats />}
|
||||||
<Panel.Section>
|
|
||||||
<OntimeCloudStats />
|
|
||||||
</Panel.Section>
|
|
||||||
)}
|
|
||||||
<div ref={logRef}>
|
<div ref={logRef}>
|
||||||
<LogExport />
|
<LogExport />
|
||||||
</div>
|
</div>
|
||||||
@@ -51,9 +51,20 @@ function OntimeCloudStats() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.SubHeader>
|
<Panel.Section>
|
||||||
Ontime cloud
|
<Panel.Card>
|
||||||
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
<Panel.SubHeader>Ontime cloud</Panel.SubHeader>
|
||||||
</Panel.SubHeader>
|
<Panel.Divider />
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Connection to the cloud server'
|
||||||
|
description='Time for a message to travel to the server and back. Lower is better'
|
||||||
|
/>
|
||||||
|
<Tag variant={ping > slowPingThreshold ? 'warning' : 'default'}>{ping}ms</Tag>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
-12
@@ -1,11 +1,3 @@
|
|||||||
.fullWidth {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.halfWidth {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.halfWidthNoWrap {
|
.halfWidthNoWrap {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -15,7 +7,3 @@
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self {
|
|
||||||
background-color: $blue-1100;
|
|
||||||
}
|
|
||||||
|
|||||||
+21
-10
@@ -8,7 +8,6 @@ import { RenameClientModal } from '../../../../../common/components/client-modal
|
|||||||
import Tag from '../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||||
import { cx } from '../../../../../common/utils/styleUtils';
|
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ClientControlPanel.module.scss';
|
import style from './ClientControlPanel.module.scss';
|
||||||
@@ -32,8 +31,19 @@ export default function ClientList() {
|
|||||||
redirectHandler.open();
|
redirectHandler.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
/**
|
||||||
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
* Clients are given by the server in connection order, which means rows move
|
||||||
|
* as clients come and go. We keep a stable order: self first, then by name
|
||||||
|
*/
|
||||||
|
const sortClients = (clientEntries: [string, Client][]) =>
|
||||||
|
clientEntries.sort(([keyA, clientA], [keyB, clientB]) => {
|
||||||
|
if (keyA === id) return -1;
|
||||||
|
if (keyB === id) return 1;
|
||||||
|
return clientA.name.localeCompare(clientB.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ontimeClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type === 'ontime'));
|
||||||
|
const otherClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type !== 'ontime'));
|
||||||
|
|
||||||
const targetClient: Client | undefined = clients[targetId];
|
const targetClient: Client | undefined = clients[targetId];
|
||||||
|
|
||||||
@@ -62,17 +72,18 @@ export default function ClientList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={{ width: '20%' }}>Client Name</td>
|
<th style={{ width: '20%' }}>Client Name</th>
|
||||||
<td>Path</td>
|
<th>Path</th>
|
||||||
<td />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
|
||||||
{ontimeClients.map(([key, client]) => {
|
{ontimeClients.map(([key, client]) => {
|
||||||
const { identify, name, path } = client;
|
const { identify, name, path } = client;
|
||||||
const isCurrent = id === key;
|
const isCurrent = id === key;
|
||||||
return (
|
return (
|
||||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
<tr key={key} data-highlight={isCurrent}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
{isCurrent && <Tag>SELF</Tag>}
|
{isCurrent && <Tag>SELF</Tag>}
|
||||||
{name}
|
{name}
|
||||||
@@ -81,7 +92,6 @@ export default function ClientList() {
|
|||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
<Button
|
<Button
|
||||||
size='small'
|
size='small'
|
||||||
className={`${identify ? style.blink : ''}`}
|
|
||||||
disabled={isCurrent}
|
disabled={isCurrent}
|
||||||
variant={identify ? 'primary' : 'subtle'}
|
variant={identify ? 'primary' : 'subtle'}
|
||||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||||
@@ -120,11 +130,12 @@ export default function ClientList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td className={style.halfWidthNoWrap}>Client Name</td>
|
<th className={style.halfWidthNoWrap}>Client Name</th>
|
||||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
<th className={style.halfWidthNoWrap}>Client type</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
|
||||||
{otherClients.map(([key, client]) => {
|
{otherClients.map(([key, client]) => {
|
||||||
const { name, type } = client;
|
const { name, type } = client;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user