mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 12:23:51 +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';
|
||||
|
||||
/** the first navigator view, used as the initial value of the view select */
|
||||
const defaultView = `/${navigatorConstants[0].url}`;
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
|
||||
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
|
||||
const { data } = useUrlPresets();
|
||||
const [path, setPath] = useState(currentPath);
|
||||
const [selected, setSelected] = useState('/');
|
||||
const [selected, setSelected] = useState(defaultView);
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
defaultValue={defaultView}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setSelected(value);
|
||||
}}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
disabled={selected === currentPath}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
import Input from '../input/input/Input';
|
||||
|
||||
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
|
||||
showCloseButton
|
||||
onClose={onClose}
|
||||
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={
|
||||
<>
|
||||
|
||||
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
|
||||
td[data-warn='true'] {
|
||||
background-color: $orange-1300;
|
||||
}
|
||||
|
||||
tr[data-highlight='true'] {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MessageTag } from 'ontime-types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { sendSocket } from '../../../../common/utils/socket';
|
||||
@@ -10,6 +11,9 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from './client-control/ClientControlPanel';
|
||||
import LogExport from './NetworkLogExport';
|
||||
|
||||
/** ping values above this are flagged to the user (ms) */
|
||||
const slowPingThreshold = 100;
|
||||
|
||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||
@@ -17,11 +21,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
{isDocker && (
|
||||
<Panel.Section>
|
||||
<OntimeCloudStats />
|
||||
</Panel.Section>
|
||||
)}
|
||||
{isDocker && <OntimeCloudStats />}
|
||||
<div ref={logRef}>
|
||||
<LogExport />
|
||||
</div>
|
||||
@@ -51,9 +51,20 @@ function OntimeCloudStats() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel.SubHeader>
|
||||
Ontime cloud
|
||||
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Ontime cloud</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 {
|
||||
width: 50%;
|
||||
white-space: nowrap;
|
||||
@@ -15,7 +7,3 @@
|
||||
cursor: 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 { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
@@ -32,8 +31,19 @@ export default function ClientList() {
|
||||
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];
|
||||
|
||||
@@ -62,17 +72,18 @@ export default function ClientList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ width: '20%' }}>Client Name</td>
|
||||
<td>Path</td>
|
||||
<td />
|
||||
<th style={{ width: '20%' }}>Client Name</th>
|
||||
<th>Path</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
|
||||
{ontimeClients.map(([key, client]) => {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||
<tr key={key} data-highlight={isCurrent}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
{isCurrent && <Tag>SELF</Tag>}
|
||||
{name}
|
||||
@@ -81,7 +92,6 @@ export default function ClientList() {
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
disabled={isCurrent}
|
||||
variant={identify ? 'primary' : 'subtle'}
|
||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||
@@ -120,11 +130,12 @@ export default function ClientList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.halfWidthNoWrap}>Client Name</td>
|
||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
||||
<th className={style.halfWidthNoWrap}>Client Name</th>
|
||||
<th className={style.halfWidthNoWrap}>Client type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
|
||||
{otherClients.map(([key, client]) => {
|
||||
const { name, type } = client;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user