From b4dcdae94564adb2db386bda115e3717e26255a8 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Mon, 9 Nov 2026 04:51:06 +0100 Subject: [PATCH 01/12] fix(web): keep what the console holds while a command is sent, name labelled players in the online column, and fit the profile and audit tables --- web/src/charts/ActivityChart.test.ts | 20 ++++++++ web/src/charts/ActivityChart.tsx | 32 +++++++----- web/src/i18n/en.ts | 5 +- web/src/pages/AuditPage.tsx | 31 +++++++----- web/src/pages/ConsolePage.test.tsx | 75 +++++++++++++++++++++++++++- web/src/pages/ConsolePage.tsx | 39 +++++++++------ web/src/pages/DiagnosticsPage.tsx | 8 ++- web/src/pages/PlayerPage.tsx | 2 +- web/src/pages/SchedulePage.tsx | 24 ++++++--- web/src/players/words.tsx | 3 ++ web/src/setup/BuildStep.tsx | 8 +-- web/src/shell/OnlineColumn.test.tsx | 35 +++++++++++++ web/src/shell/OnlineColumn.tsx | 34 +++++++++---- web/src/shell/StatusBar.tsx | 16 ++++-- 14 files changed, 261 insertions(+), 71 deletions(-) create mode 100644 web/src/charts/ActivityChart.test.ts diff --git a/web/src/charts/ActivityChart.test.ts b/web/src/charts/ActivityChart.test.ts new file mode 100644 index 00000000..c15db1fc --- /dev/null +++ b/web/src/charts/ActivityChart.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { atLeastAnHour } from './ActivityChart'; + +describe('the activity scale', () => { + it('should span an hour at least, so that minutes read on distinct marks', () => { + expect(atLeastAnHour({ priceRange: { minValue: 0, maxValue: 0.03 } }).priceRange).toEqual({ + minValue: 0, + maxValue: 1, + }); + expect(atLeastAnHour(null).priceRange).toEqual({ minValue: 0, maxValue: 1 }); + }); + + it('should keep a longer day as it is', () => { + expect(atLeastAnHour({ priceRange: { minValue: 0, maxValue: 3.5 } }).priceRange).toEqual({ + minValue: 0, + maxValue: 3.5, + }); + }); +}); diff --git a/web/src/charts/ActivityChart.tsx b/web/src/charts/ActivityChart.tsx index 2d748f5a..25e2e99a 100644 --- a/web/src/charts/ActivityChart.tsx +++ b/web/src/charts/ActivityChart.tsx @@ -1,6 +1,7 @@ import { HistogramSeries, createChart, + type AutoscaleInfo, type ChartOptions, type DeepPartial, type IChartApi, @@ -10,23 +11,32 @@ import { } from 'lightweight-charts'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { FIGURE, formatNumber } from '../i18n/format'; -import type { Messages } from '../i18n/en'; +import { formatPlayTime } from '../i18n/format'; import { useI18n } from '../i18n/I18nProvider'; import { dayChartOptions } from './days'; import { tokenColor } from './theme'; const HOUR = 3_600_000; -/** The scale reads in hours: `1.5h`; a bar's own time, in the tooltip, to the minute. */ -const activityOptions = (host: Element, locale: string, t: Messages): DeepPartial => { +/** + * The scale spans an hour at least, so that a few minutes played still read on distinct marks + * (`12 min`, `24 min`…) rather than on several `0h`. + */ +export const atLeastAnHour = (info: AutoscaleInfo | null): AutoscaleInfo => ({ + ...info, + priceRange: { minValue: 0, maxValue: Math.max(1, info?.priceRange?.maxValue ?? 0) }, +}); + +/** The scale's marks, in the words play time is written in everywhere else: `24 min`, `1h 30m`. */ +const activityOptions = (host: Element, locale: string): DeepPartial => { const base = dayChartOptions(host, locale); return { ...base, + // The library sizes the scale on its first and last marks only (`0h`, `1h`): `48 min` needs more. + leftPriceScale: { ...base.leftPriceScale, minimumWidth: 56 }, localization: { ...base.localization, - priceFormatter: (hours: number) => - t.players.profile.activity.hours(formatNumber(locale, hours, FIGURE)), + priceFormatter: (hours: number) => formatPlayTime(locale, hours * HOUR), }, }; }; @@ -51,18 +61,16 @@ export function ActivityChart({ days, today }: { days: { day: string; playMs: nu if (element === null) { return; } - const api = createChart( - element, - activityOptions(element, localeRef.current.locale, localeRef.current.t), - ); + const api = createChart(element, activityOptions(element, localeRef.current.locale)); bars.current = api.addSeries(HistogramSeries, { priceScaleId: 'left', priceLineVisible: false, lastValueVisible: false, + autoscaleInfoProvider: atLeastAnHour, }); chart.current = api; const paint = () => { - api.applyOptions(activityOptions(element, localeRef.current.locale, localeRef.current.t)); + api.applyOptions(activityOptions(element, localeRef.current.locale)); bars.current?.applyOptions({ color: tokenColor(element, '--chart-muted') }); }; paint(); @@ -90,7 +98,7 @@ export function ActivityChart({ days, today }: { days: { day: string; playMs: nu localeRef.current = { locale, t }; const element = host.current; if (element !== null) { - chart.current?.applyOptions(activityOptions(element, locale, t)); + chart.current?.applyOptions(activityOptions(element, locale)); } }, [locale, t]); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2f0d2173..127059f1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1399,7 +1399,8 @@ export const en = { countsFromStart: 'Counts from the next start of FXServer.', emptyTitle: 'No scheduled restart', emptyBody: - 'Add a fixed time or an interval below. Your resources hear of each restart before it comes.', + 'Choose fixed times or an interval in the restart settings. Your resources hear of each restart before it comes.', + setUp: 'Set up restarts', actions: 'Restart actions', skip: 'Skip', restore: 'Restore', @@ -1651,6 +1652,7 @@ export const en = { protocols: { tcp: 'TCP', udp: 'UDP' }, none: 'No connection in the last 24 hours.', noListener: 'Nothing listens while FXServer is stopped.', + noListenerSeen: "No socket of FXServer's tree listens right now.", footer: 'Connections shorter than 2 s may not show. Windows lists UDP sockets without their remote end.', }, exceptions: { @@ -1812,7 +1814,6 @@ export const en = { today: (time: string) => `Today, so far: ${time}.`, tooltip: (time: string) => `${time} played`, todaySoFar: 'today, so far', - hours: (hours: string) => `${hours}h`, }, names: { title: 'Names', diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index 8905568f..47121445 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -239,15 +239,9 @@ export function AuditPage() { {t.audit.action} - - {t.audit.target} - {t.audit.outcome} - - {t.audit.address} - {t.audit.details} @@ -295,18 +289,29 @@ function EntryRow({ entry }: { entry: AuditEntry }) { {entry.actorKind !== 'user' && entry.actorName !== null && (
{t.audit.actors[entry.actorKind]}
)} + {entry.ip !== null && ( +
+ {t.audit.address}: + {entry.ip} +
+ )} - {t.audit.actions[entry.action]} - - {entry.target ?? - (entry.action === 'login_failed' && ( - {t.audit.unknownUsername} - ))} + + {t.audit.actions[entry.action]} + {(entry.target !== null || entry.action === 'login_failed') && ( +
+ {t.audit.target}: + {entry.target === null ? ( + t.audit.unknownUsername + ) : ( + {entry.target} + )} +
+ )} {t.audit.outcomes[entry.outcome]} - {entry.ip ?? ''} {auditDetails(t, format, entry.details, entry.action)} diff --git a/web/src/pages/ConsolePage.test.tsx b/web/src/pages/ConsolePage.test.tsx index 2528d873..9ef9e4c7 100644 --- a/web/src/pages/ConsolePage.test.tsx +++ b/web/src/pages/ConsolePage.test.tsx @@ -60,7 +60,11 @@ const screenIs = (phone: boolean) => * The console of an operator who reads it, holding `count` lines. `meanwhile` is what the reader * does before the first lines come. */ -const consoleWith = async (count: number, meanwhile: (view: RenderResult) => void = () => {}) => { +const consoleWith = async ( + count: number, + meanwhile: (view: RenderResult) => void = () => {}, + permissions = '9', +) => { let socket: SocketEvents | undefined; const live = new Realtime({ ticket: async () => 'ticket', @@ -78,7 +82,7 @@ const consoleWith = async (count: number, meanwhile: (view: RenderResult) => voi id: 2, username: 'kenji', isOwner: false, - permissions: '9', + permissions, mustChangePassword: false, }} > @@ -209,3 +213,70 @@ describe('ConsolePage', () => { live.stop(); }); }); + +const typeAndSend = (field: HTMLElement, command: string) => { + fireEvent.change(field, { target: { value: command } }); + fireEvent.submit(field.closest('form') as HTMLFormElement); +}; + +describe('ConsolePage command line', () => { + /** The console commands Observer was sent, each answered when the test says. */ + const sent: { command: string; answer: (delivery: string) => void }[] = []; + + beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); + screenIs(false); + Element.prototype.scrollIntoView = () => {}; + vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => { + if (url.endsWith('/console') && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as { line: string }; + const delivery = await new Promise((answer) => + sent.push({ command: body.line, answer }), + ); + return Response.json({ delivery, masked: false }); + } + return Response.json({ code: 'not_found' }, { status: 404 }); + }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + observed.clear(); + sent.length = 0; + localStorage.clear(); + }); + + it('should keep what is typed while a command is on its way, and send both in order', async () => { + const { getByRole, live } = await consoleWith(0, () => {}, '7'); + const field = getByRole('textbox', { name: en.console.commandLabel }); + + typeAndSend(field, 'status'); + expect(field).toHaveProperty('value', ''); + typeAndSend(field, 'players'); + fireEvent.change(field, { target: { value: 'restart hello' } }); + await settle(); + sent[0]?.answer('written'); + await settle(); + sent[1]?.answer('written'); + await settle(); + + expect(sent.map(({ command }) => command)).toEqual(['status', 'players']); + expect(field).toHaveProperty('value', 'restart hello'); + live.stop(); + }); + + it('should give a refused command back to the field when nothing else was typed', async () => { + const { getByRole, live } = await consoleWith(0, () => {}, '7'); + const field = getByRole('textbox', { name: en.console.commandLabel }); + + typeAndSend(field, 'status'); + await settle(); + sent[0]?.answer('busy'); + await settle(); + + expect(field).toHaveProperty('value', 'status'); + expect(getByRole('alert').textContent).toBe(en.console.busy); + live.stop(); + }); +}); diff --git a/web/src/pages/ConsolePage.tsx b/web/src/pages/ConsolePage.tsx index b2d7cac4..ed44fa5d 100644 --- a/web/src/pages/ConsolePage.tsx +++ b/web/src/pages/ConsolePage.tsx @@ -407,37 +407,48 @@ function CommandLine({ onSent }: { onSent: (command: string, masked: boolean) => const [command, setCommand] = useState(''); const [history, setHistory] = useState(() => readHistory(me.username)); const [position, setPosition] = useState(START); - const [sending, setSending] = useState(false); + const [sending, setSending] = useState(0); const [feedback, setFeedback] = useState(null); + // Commands typed while one is on its way go out after it, in the order they were typed. + const queue = useRef>(Promise.resolve()); if (!allows(me, Permission.ConsoleWrite)) { return

{t.console.readOnly}

; } const { open, note } = commandLine(t, status); - const submit = async (event: FormEvent) => { - event.preventDefault(); - const line = command.trim(); - if (line === '' || sending) { - return; - } - setSending(true); + const send = async (line: string) => { + // A refused line comes back to the field, unless something else was typed there since. + const giveBack = () => setCommand((typed) => (typed === '' ? line : typed)); try { const { delivery, masked } = await sendCommand(line); if (delivery === 'busy') { setFeedback({ tone: 'danger', text: t.console.busy }); + giveBack(); return; } - setHistory(remember(me.username, history, line, masked)); - setPosition(START); - setCommand(''); + setHistory((current) => remember(me.username, current, line, masked)); setFeedback(delivery === 'pending' ? { tone: 'warning', text: t.console.pending } : null); onSent(line, masked); } catch (error) { setFeedback({ tone: 'danger', text: errorMessage(t, error) }); + giveBack(); } finally { - setSending(false); + setSending((count) => count - 1); + } + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + const line = command.trim(); + if (line === '') { + return; } + setCommand(''); + setPosition(START); + setFeedback(null); + setSending((count) => count + 1); + queue.current = queue.current.then(() => send(line)); }; const onKeyDown = (event: KeyboardEvent) => { @@ -454,7 +465,7 @@ function CommandLine({ onSent }: { onSent: (command: string, masked: boolean) => return ( <> -
void submit(event)}> +