mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 20:13:49 +01:00
web: system and setup pages paint glyphs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
30416da9b8
commit
f74e70221f
@ -29,19 +29,23 @@ describe('setup-form', () => {
|
||||
expect(keyBox()).toHaveValue('')
|
||||
expect(keyBox()).toHaveFocus()
|
||||
await waitFor(() => {
|
||||
expect(keyBox()).toHaveAttribute('placeholder', expect.stringContaining('A key is stored'))
|
||||
expect(keyBox()).toHaveAttribute('data-stored', 'true')
|
||||
})
|
||||
expect(keyBox().placeholder).toMatch(/^🔒 •{32}$/)
|
||||
expect(idBox()).toHaveValue(ID)
|
||||
expect(screen.queryByText('Not configured yet')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'steamcommunity.com/dev/apikey' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'steamcommunity.com/dev/apikey' })).toHaveAttribute('href', 'https://steamcommunity.com/dev/apikey')
|
||||
expect(screen.getByRole('link', { name: 'steamid.io' })).toHaveAttribute('href', 'https://steamid.io')
|
||||
})
|
||||
|
||||
it('warns on a first run, with a hint for the empty form', async () => {
|
||||
stubApi({ 'GET /api/setup': status({ configured: false, has_api_key: false, steam_id: null }) })
|
||||
show()
|
||||
expect(await screen.findByText('Not configured yet')).toBeInTheDocument()
|
||||
expect(keyBox()).toHaveAttribute('placeholder', '32 hex characters')
|
||||
expect(keyBox()).toHaveAttribute('data-stored', 'false')
|
||||
expect(keyBox().placeholder).toHaveLength(32)
|
||||
expect(idBox()).toHaveValue('')
|
||||
expect(idBox().placeholder).toMatch(/^7656·{13}$/)
|
||||
})
|
||||
|
||||
it('shows loading', async () => {
|
||||
@ -68,6 +72,8 @@ describe('setup-form', () => {
|
||||
expect(screen.getByText('A SteamID64 is 17 digits and starts with 7656.')).toBeInTheDocument()
|
||||
expect(keyBox()).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(keyBox()).toHaveClass('input-bad')
|
||||
expect(keyBox().nextElementSibling).toHaveTextContent('⚠️ 6/32')
|
||||
expect(idBox().nextElementSibling).toHaveTextContent('⚠️ 3/17')
|
||||
expect(bodiesOf(mock, 'POST /api/setup')).toEqual([])
|
||||
await userEvent.clear(keyBox())
|
||||
await userEvent.type(keyBox(), KEY)
|
||||
|
||||
@ -10,7 +10,11 @@ import { Action } from '../lib/action'
|
||||
import { append, define, h, type Child } from '../lib/dom'
|
||||
import { LiveElement } from '../lib/element'
|
||||
import { invalidateAll } from '../lib/query'
|
||||
import { card, errorNotice, loading, notice } from '../ui/build'
|
||||
import { card, errorNotice, glyph, loading, notice } from '../ui/build'
|
||||
|
||||
/** A notice's title and sentence, spoken only (textgate); its tone icon is painted. */
|
||||
const spoken = (title: string, text: string): HTMLSpanElement =>
|
||||
h('span', { class: 'sr-only' }, h('strong', {}, title), ` ${text}`)
|
||||
|
||||
const KEY_RE = /^[0-9A-Fa-f]{32}$/
|
||||
const ID_RE = /^7656\d{13}$/
|
||||
@ -23,27 +27,44 @@ export function idError(id: string): string | null {
|
||||
return ID_RE.test(id.trim()) ? null : 'A SteamID64 is 17 digits and starts with 7656.'
|
||||
}
|
||||
|
||||
const KEY_HELP = (): Child[] => [
|
||||
'Get one at ',
|
||||
h('a', { href: 'https://steamcommunity.com/dev/apikey', target: '_blank', rel: 'noreferrer' }, 'steamcommunity.com/dev/apikey'),
|
||||
'. It is never shown again.',
|
||||
// Textgate: help is a 🔗 to where the value comes from; its sentence is spoken only.
|
||||
const helpLink = (href: string, spoken: string, sentence: string): Child[] => [
|
||||
h('a', { href, target: '_blank', rel: 'noreferrer', class: 'setup-link', 'aria-label': spoken }, '🔗'),
|
||||
h('span', { class: 'sr-only' }, sentence),
|
||||
]
|
||||
const ID_HELP = (): Child[] => ['Your profile URL or steamid.io shows it.']
|
||||
const KEY_HELP = (): Child[] =>
|
||||
helpLink('https://steamcommunity.com/dev/apikey', 'steamcommunity.com/dev/apikey', 'Get one at steamcommunity.com/dev/apikey. It is never shown again.')
|
||||
const ID_HELP = (): Child[] => helpLink('https://steamid.io', 'steamid.io', 'Your profile URL or steamid.io shows it.')
|
||||
|
||||
/** Placeholders paint the expected shape: one dot per character, the fixed 7656 prefix. */
|
||||
const KEY_SHAPE = '·'.repeat(32)
|
||||
const KEY_STORED = `🔒 ${'•'.repeat(32)}`
|
||||
const ID_SHAPE = `7656${'·'.repeat(13)}`
|
||||
|
||||
interface Field {
|
||||
input: HTMLInputElement
|
||||
help: HTMLParagraphElement
|
||||
hint: () => Child[]
|
||||
baseClass: string
|
||||
/** Characters a valid value has; a bad value paints `⚠️ typed/want`. */
|
||||
want: number
|
||||
}
|
||||
|
||||
function field(id: string, label: string, input: HTMLInputElement, hint: () => Child[]): { el: HTMLDivElement; f: Field } {
|
||||
function field(id: string, label: string, icon: string, input: HTMLInputElement, hint: () => Child[], want: number): { el: HTMLDivElement; f: Field } {
|
||||
const help = h('p', { id: `${id}-help`, class: 'field-help' }, hint())
|
||||
input.id = id
|
||||
input.setAttribute('aria-describedby', `${id}-help`)
|
||||
return {
|
||||
el: h('div', { class: 'form-field' }, h('label', { for: id, class: 'field-label' }, label), input, help),
|
||||
f: { input, help, hint, baseClass: input.className },
|
||||
el: h(
|
||||
'div',
|
||||
{ class: 'form-field' },
|
||||
// The glyph is painted beside a spoken-only <label>, so the label's text stays exactly `label`.
|
||||
h('span', { class: 'field-label', 'aria-hidden': 'true' }, icon),
|
||||
h('label', { for: id, class: 'sr-only' }, label),
|
||||
input,
|
||||
help,
|
||||
),
|
||||
f: { input, help, hint, baseClass: input.className, want },
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,10 +92,19 @@ export class SetupFormElement extends LiveElement {
|
||||
const key = field(
|
||||
'setup-key',
|
||||
'Steam Web API key',
|
||||
'🔑',
|
||||
h('input', { class: 'input mono', type: 'password', autocomplete: 'off', spellcheck: 'false', autofocus: true }),
|
||||
KEY_HELP,
|
||||
32,
|
||||
)
|
||||
const id = field(
|
||||
'setup-id',
|
||||
'SteamID64',
|
||||
'🆔',
|
||||
h('input', { class: 'input mono input-num', inputmode: 'numeric', placeholder: ID_SHAPE }),
|
||||
ID_HELP,
|
||||
17,
|
||||
)
|
||||
const id = field('setup-id', 'SteamID64', h('input', { class: 'input mono input-num', inputmode: 'numeric' }), ID_HELP)
|
||||
this.keyField = key.f
|
||||
this.idField = id.f
|
||||
key.f.input.addEventListener('input', () => {
|
||||
@ -116,10 +146,12 @@ export class SetupFormElement extends LiveElement {
|
||||
...(error ? [errorNotice(error)] : []),
|
||||
...(!data && !error ? [loading('setup')] : []),
|
||||
...(data && !data.configured
|
||||
? [notice('warning', 'Not configured yet', 'Every other screen needs these two values first.')]
|
||||
? [notice('warning', null, spoken('Not configured yet', 'Every other screen needs these two values first.'))]
|
||||
: []),
|
||||
)
|
||||
this.keyField.input.placeholder = data?.has_api_key ? 'A key is stored — type a new one to replace it' : '32 hex characters'
|
||||
// 🔒 + filled dots: a key is stored (typing replaces it); hollow dots: none yet.
|
||||
this.keyField.input.placeholder = data?.has_api_key ? KEY_STORED : KEY_SHAPE
|
||||
this.keyField.input.dataset.stored = String(data?.has_api_key ?? false)
|
||||
if (!this.idEdited) this.idField.input.value = data?.steam_id ?? ''
|
||||
this.render()
|
||||
}
|
||||
@ -130,24 +162,35 @@ export class SetupFormElement extends LiveElement {
|
||||
this.mark(this.idField, idError(this.idField.input.value))
|
||||
this.saved.replaceChildren(
|
||||
...(this.success
|
||||
? [notice('success', 'Saved', 'Steam accepted the key. ', h('a', { href: '/' }, 'Go to the dashboard'), '.')]
|
||||
? [
|
||||
notice(
|
||||
'success',
|
||||
null,
|
||||
spoken('Saved', 'Steam accepted the key.'),
|
||||
h('a', { href: '/', class: 'btn btn-ghost btn-small', 'aria-label': 'Go to the dashboard' }, '🏠'),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
)
|
||||
this.failure.replaceChildren(...(this.save.error ? [errorNotice(this.save.error)] : []))
|
||||
this.submit.disabled = this.save.pending
|
||||
this.submit.textContent = this.save.pending ? 'Checking with Steam…' : 'Save and verify'
|
||||
// ⏳ while Steam checks, ✔️ to save; the words are spoken only.
|
||||
this.submit.replaceChildren(this.save.pending ? glyph('⏳', 'Checking with Steam…') : glyph('✔️', 'Save and verify'))
|
||||
}
|
||||
|
||||
private mark(f: Field, problem: string | null): void {
|
||||
const bad = this.touched && problem !== null
|
||||
f.input.className = bad ? `${f.baseClass} input-bad` : f.baseClass
|
||||
f.input.setAttribute('aria-invalid', String(bad))
|
||||
const shown = f.help.dataset.bad === String(bad) && (!bad || f.help.textContent === problem)
|
||||
if (shown) return
|
||||
// A bad value paints ⚠️ and how many characters it has of the `want` it needs.
|
||||
const count = `⚠️ ${f.input.value.trim().length}/${f.want}`
|
||||
const state = bad ? `${problem}|${count}` : 'ok'
|
||||
if (f.help.dataset.state === state) return
|
||||
f.help.dataset.state = state
|
||||
f.help.dataset.bad = String(bad)
|
||||
f.help.className = bad ? 'field-help text-danger' : 'field-help'
|
||||
f.help.replaceChildren()
|
||||
append(f.help, bad ? problem : f.hint())
|
||||
append(f.help, bad ? [h('span', { 'aria-hidden': 'true', class: 'mono' }, count), h('span', { class: 'sr-only' }, problem)] : f.hint())
|
||||
}
|
||||
|
||||
private async store(): Promise<void> {
|
||||
|
||||
@ -25,7 +25,7 @@ const openDialog = async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Restore…' }))
|
||||
return screen.findByRole('dialog', { name: `Restore backup ${BACKUP.id}` })
|
||||
}
|
||||
const phraseBox = () => screen.getByLabelText('Type the phrase to confirm')
|
||||
const phraseBox = () => screen.getByLabelText(/Type the phrase to confirm/)
|
||||
|
||||
describe('restorePhrase', () => {
|
||||
it('mirrors the server friction phrase', () => {
|
||||
@ -40,7 +40,7 @@ describe('backups-card', () => {
|
||||
show()
|
||||
expect(await screen.findByText(BACKUP.id)).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'State backups' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/before reset · 2.0 KB/)).toBeInTheDocument()
|
||||
expect(document.querySelector('[data-reason="before reset"]')).toHaveTextContent(/♻️.*before reset · 2.0 KB/)
|
||||
expect(await screen.findByRole('button', { name: 'Reset all state…' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ import '../../jobs/jobProgress'
|
||||
import { Action } from '../../lib/action'
|
||||
import { define, h, hx } from '../../lib/dom'
|
||||
import { LiveElement } from '../../lib/element'
|
||||
import { card, empty, errorNotice, loading } from '../../ui/build'
|
||||
import { card, empty, errorNotice, glyph, loading } from '../../ui/build'
|
||||
import { Modal } from '../../ui/dialog'
|
||||
import { bytes, dateTime } from '../../ui/format'
|
||||
|
||||
@ -23,6 +23,10 @@ import { bytes, dateTime } from '../../ui/format'
|
||||
// know it. Mirrors FRICTION["restore-backup"] in _friction.py.
|
||||
export const restorePhrase = (id: string): string => `restore backup ${id}`
|
||||
|
||||
/** Why a backup was taken, as a glyph (reason words spoken only). */
|
||||
const REASON_ICON: Record<string, string> = { 'before reset': '♻️', nightly: '🌙' }
|
||||
const reasonGlyph = (reason: string): HTMLSpanElement => glyph(REASON_ICON[reason] ?? '🗄️', reason)
|
||||
|
||||
/** One restore: phrase form, then the job it started. */
|
||||
export class RestoreDialog {
|
||||
readonly modal: Modal
|
||||
@ -57,14 +61,21 @@ export class RestoreDialog {
|
||||
if (this.matches() && !this.restore.pending) void this.run(onClose)
|
||||
},
|
||||
},
|
||||
h('p', { class: 'm0' }, `Replaces the current state with the backup from ${dateTime(backup.created_at)} (${backup.reason}).`),
|
||||
// Painted: the backup's id, date and reason glyph; the sentence is spoken only.
|
||||
h(
|
||||
'p',
|
||||
{ class: 'm0 row' },
|
||||
h('span', { class: 'sr-only' }, `Replaces the current state with the backup from ${dateTime(backup.created_at)} (${backup.reason}).`),
|
||||
h('span', { 'aria-hidden': 'true', class: 'mono' }, `🗄️ ${backup.id} · ${dateTime(backup.created_at)}`),
|
||||
h('span', { 'aria-hidden': 'true' }, REASON_ICON[backup.reason] ?? '🗄️'),
|
||||
),
|
||||
this.field.el,
|
||||
this.failure,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'actions' },
|
||||
this.submit,
|
||||
h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, 'Cancel'),
|
||||
h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, glyph('✖️', 'Cancel')),
|
||||
),
|
||||
)
|
||||
this.modal.render(form)
|
||||
@ -77,7 +88,7 @@ export class RestoreDialog {
|
||||
|
||||
private sync(): void {
|
||||
this.submit.disabled = !this.matches() || this.restore.pending
|
||||
this.submit.textContent = this.restore.pending ? 'Restoring…' : 'Restore'
|
||||
this.submit.replaceChildren(this.restore.pending ? glyph('⏳', 'Restoring…') : glyph('⏮️', 'Restore'))
|
||||
this.failure.replaceChildren(...(this.restore.error ? [errorNotice(this.restore.error)] : []))
|
||||
}
|
||||
|
||||
@ -90,7 +101,7 @@ export class RestoreDialog {
|
||||
'div',
|
||||
{ class: 'stack' },
|
||||
hx('job-progress', { 'job-id': out.value.id }),
|
||||
h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, 'Close')),
|
||||
h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, glyph('✖️', 'Close'))),
|
||||
),
|
||||
)
|
||||
this.modal.focusFirst()
|
||||
@ -102,8 +113,8 @@ export class BackupsCardElement extends LiveElement {
|
||||
private readonly list = h('ul', { class: 'rows' })
|
||||
private readonly root = card(
|
||||
'State backups',
|
||||
[h('p', { class: 'field-help m0' }, 'Reset takes a timestamped backup first, so it can be undone here.'), this.status, this.list],
|
||||
{ aside: hx('command-button', { name: 'reset', label: 'Reset all state…' }) },
|
||||
[h('p', { class: 'sr-only' }, 'Reset takes a timestamped backup first, so it can be undone here.'), this.status, this.list],
|
||||
{ aside: hx('command-button', { name: 'reset', label: 'Reset all state…' }), icon: '🗄️' },
|
||||
)
|
||||
private dialog: RestoreDialog | null = null
|
||||
private shownKey = ''
|
||||
@ -134,7 +145,13 @@ export class BackupsCardElement extends LiveElement {
|
||||
'li',
|
||||
{ class: 'row' },
|
||||
h('span', { class: 'mono' }, b.id),
|
||||
h('span', { class: 'muted grow' }, `${dateTime(b.created_at)} · ${b.reason} · ${bytes(b.size_bytes)}`),
|
||||
h(
|
||||
'span',
|
||||
{ class: 'muted grow', 'data-reason': b.reason },
|
||||
`${dateTime(b.created_at)} · `,
|
||||
reasonGlyph(b.reason),
|
||||
` · ${bytes(b.size_bytes)}`,
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
@ -144,7 +161,7 @@ export class BackupsCardElement extends LiveElement {
|
||||
this.open(b)
|
||||
},
|
||||
},
|
||||
'Restore…',
|
||||
glyph('⏮️', 'Restore…'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -44,7 +44,8 @@ describe('daemon-card', () => {
|
||||
expect(await screen.findByText('4242')).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'Enforcer daemon' })).toBeInTheDocument()
|
||||
expect(screen.getByText('running')).toBeInTheDocument()
|
||||
expect(screen.getByText(/ago/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/ago/)).toBeInTheDocument()
|
||||
expect(screen.getByText('running').closest('[data-state]')).toHaveAttribute('data-state', 'running')
|
||||
expect(journal()).toHaveTextContent(/line one\s+line two/)
|
||||
expect(screen.queryByText('Daemon unreachable')).toBeNull()
|
||||
})
|
||||
@ -77,8 +78,8 @@ describe('daemon-card', () => {
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true')
|
||||
await userEvent.click(button)
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('No journal lines.')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('—')).toHaveLength(2)
|
||||
expect(journal()).toHaveTextContent(/^—$/)
|
||||
expect(screen.getAllByText('—')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('offers the demo run', async () => {
|
||||
|
||||
@ -8,19 +8,36 @@ import { q } from '../../api/queries'
|
||||
import { define, h } from '../../lib/dom'
|
||||
import { LiveElement } from '../../lib/element'
|
||||
import { runner } from '../../lib/runner'
|
||||
import { card, commandLine, errorNotice, facts, loading, notice } from '../../ui/build'
|
||||
import type { DaemonState } from '../../api/contract'
|
||||
import { card, commandLine, errorNotice, facts, glyph, loading, notice } from '../../ui/build'
|
||||
import { ago, clock } from '../../ui/format'
|
||||
|
||||
/** "3 h ago" painted as "3 h" (a number + unit), the full phrase spoken. */
|
||||
function since(iso: string): HTMLSpanElement {
|
||||
const full = ago(iso)
|
||||
const painted = full === 'just now' ? '0 min' : full.replace(/ ago$/, '')
|
||||
return h('span', { 'aria-label': full }, h('span', { 'aria-hidden': 'true' }, painted))
|
||||
}
|
||||
|
||||
/** Milliseconds left on the restart rate limit; 0 when free. */
|
||||
export function restartWaitMs(availableAt: string | null, now: number = Date.now()): number {
|
||||
return availableAt ? Math.max(0, Date.parse(availableAt) - now) : 0
|
||||
}
|
||||
|
||||
/** Daemon state: glyph painted, the state word spoken. */
|
||||
const STATE_ICON: Record<DaemonState, string> = { running: '🟢', restarting: '🔄', unreachable: '🔴' }
|
||||
|
||||
function unreachableNotice(): HTMLDivElement {
|
||||
// Textgate: ⛔ + the command to run are painted; the explanation is spoken only.
|
||||
return notice(
|
||||
'danger',
|
||||
'Daemon unreachable',
|
||||
'Privileged actions (store unblock, gaming reset/block/unblock, restart) are unavailable. Check it from a terminal:',
|
||||
null,
|
||||
h(
|
||||
'span',
|
||||
{ class: 'sr-only' },
|
||||
h('strong', {}, 'Daemon unreachable'),
|
||||
' Privileged actions (store unblock, gaming reset/block/unblock, restart) are unavailable. Check it from a terminal:',
|
||||
),
|
||||
commandLine('systemctl status steam-backlog-enforcer'),
|
||||
)
|
||||
}
|
||||
@ -49,16 +66,16 @@ export class DaemonCardElement extends LiveElement {
|
||||
runner.run('enforce', { demo: 1 })
|
||||
},
|
||||
},
|
||||
'Demo run (enforce --demo)…',
|
||||
glyph('🧪', 'Demo run (enforce --demo)…'),
|
||||
)
|
||||
private readonly parts = [
|
||||
this.alert,
|
||||
this.factsSlot,
|
||||
h('div', { class: 'actions' }, this.restart, this.demo),
|
||||
h('h3', { class: 'h3' }, 'Journal'),
|
||||
h('h3', { class: 'h3' }, glyph('📜', 'Journal')),
|
||||
this.journal,
|
||||
]
|
||||
private readonly root = card('Enforcer daemon', [this.status, this.body])
|
||||
private readonly root = card('Enforcer daemon', [this.status, this.body], { icon: '🛡️' })
|
||||
private shown = false
|
||||
private canRestart = false
|
||||
private unreachable = false
|
||||
@ -87,20 +104,27 @@ export class DaemonCardElement extends LiveElement {
|
||||
}
|
||||
this.factsSlot.replaceChildren(
|
||||
facts([
|
||||
['State', data.state],
|
||||
['PID', data.pid ?? '—'],
|
||||
['Up since', data.started_at ? ago(data.started_at) : '—'],
|
||||
[glyph('📡', 'State'), h('span', { 'data-state': data.state }, glyph(STATE_ICON[data.state], data.state))],
|
||||
[glyph('#️⃣', 'PID'), data.pid ?? '—'],
|
||||
[glyph('⏱️', 'Up since'), data.started_at ? since(data.started_at) : '—'],
|
||||
]),
|
||||
)
|
||||
this.canRestart = waitMs === 0 && data.state === 'running'
|
||||
this.restart.setAttribute('aria-disabled', String(!this.canRestart))
|
||||
this.restart.textContent = waitMs > 0 ? `Restart available in ${clock(waitMs / 1000)}` : 'Restart daemon…'
|
||||
// Rate limited: ⏳ + the clock counting down; free: 🔄. Words spoken only.
|
||||
const wait = clock(waitMs / 1000)
|
||||
this.restart.replaceChildren(
|
||||
waitMs > 0
|
||||
? h('span', { 'aria-label': `Restart available in ${wait}` }, h('span', { 'aria-hidden': 'true' }, `⏳ ${wait}`))
|
||||
: glyph('🔄', 'Restart daemon…'),
|
||||
)
|
||||
this.fillJournal(data.journal_tail)
|
||||
}
|
||||
|
||||
/** New text only when it changed; follow the tail unless scrolled up. */
|
||||
private fillJournal(lines: string[]): void {
|
||||
const text = lines.length ? lines.join('\n') : 'No journal lines.'
|
||||
// The journal lines are the daemon's own log (content); empty paints a dash.
|
||||
const text = lines.length ? lines.join('\n') : '—'
|
||||
const j = this.journal
|
||||
if (j.textContent === text) return
|
||||
const following = j.scrollHeight - j.scrollTop - j.clientHeight < 8
|
||||
|
||||
@ -6,7 +6,7 @@ import { q } from '../../api/queries'
|
||||
import { define, h } from '../../lib/dom'
|
||||
import { LiveElement } from '../../lib/element'
|
||||
import { ServerRestart } from '../../shell/serverRestart'
|
||||
import { card, errorNotice, facts, loading, notice } from '../../ui/build'
|
||||
import { card, errorNotice, facts, glyph, loading, notice } from '../../ui/build'
|
||||
import { ago, dateTime } from '../../ui/format'
|
||||
|
||||
export class ServerCardElement extends LiveElement {
|
||||
@ -19,7 +19,7 @@ export class ServerCardElement extends LiveElement {
|
||||
void this.restart.action.run()
|
||||
},
|
||||
})
|
||||
private readonly root = card('Web server', [this.info, this.failure, h('div', { class: 'actions' }, this.button)])
|
||||
private readonly root = card('Web server', [this.info, this.failure, h('div', { class: 'actions' }, this.button)], { icon: '🌐' })
|
||||
private readonly restart = new ServerRestart(() => {
|
||||
this.update()
|
||||
})
|
||||
@ -37,13 +37,13 @@ export class ServerCardElement extends LiveElement {
|
||||
this.info.replaceChildren(
|
||||
...(error ? [errorNotice(error)] : []),
|
||||
...(!data && !error ? [loading('server health')] : []),
|
||||
...(data?.stale ? [notice('warning', 'Stale', 'The code on disk is newer than this process.')] : []),
|
||||
...(data?.stale ? [notice('warning', null, h('span', { class: 'sr-only' }, h('strong', {}, 'Stale'), ' The code on disk is newer than this process.'))] : []),
|
||||
...(data
|
||||
? [
|
||||
facts([
|
||||
['Version', h('span', { class: 'mono' }, data.version)],
|
||||
['Started', `${dateTime(data.started_at)} (${ago(data.started_at)})`],
|
||||
['Code', data.stale ? 'stale' : 'current'],
|
||||
[glyph('🏷️', 'Version'), h('span', { class: 'mono' }, data.version)],
|
||||
[glyph('🕒', 'Started'), h('span', { 'aria-label': `${dateTime(data.started_at)} (${ago(data.started_at)})` }, dateTime(data.started_at))],
|
||||
[glyph('💾', 'Code'), data.stale ? glyph('⚠️', 'stale') : glyph('✅', 'current')],
|
||||
]),
|
||||
]
|
||||
: []),
|
||||
@ -51,7 +51,8 @@ export class ServerCardElement extends LiveElement {
|
||||
const { action, restarting } = this.restart
|
||||
this.failure.replaceChildren(...(action.error ? [errorNotice(action.error)] : []))
|
||||
this.button.disabled = restarting || action.pending
|
||||
this.button.textContent = restarting ? 'Restarting… the page reloads when it is back' : 'Restart server'
|
||||
this.button.dataset.restarting = String(restarting)
|
||||
this.button.replaceChildren(restarting ? glyph('⏳', 'Restarting… the page reloads when it is back') : glyph('🔄', 'Restart server'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { q } from '../../api/queries'
|
||||
import { define, h } from '../../lib/dom'
|
||||
import { LiveElement } from '../../lib/element'
|
||||
import { pageHref } from '../../lib/runner'
|
||||
import { card, facts, link } from '../../ui/build'
|
||||
import { card, facts, glyph, link } from '../../ui/build'
|
||||
|
||||
export class SetupCardElement extends LiveElement {
|
||||
protected override setup(): void {
|
||||
@ -18,11 +18,11 @@ export class SetupCardElement extends LiveElement {
|
||||
card(
|
||||
'Setup',
|
||||
facts([
|
||||
['Configured', data ? (data.configured ? 'Yes' : 'No') : '…'],
|
||||
['Steam API key', data ? (data.has_api_key ? 'Stored (never shown)' : 'Missing') : '…'],
|
||||
['Steam ID', h('span', { class: 'mono' }, data?.steam_id ?? '—')],
|
||||
[glyph('⚙️', 'Configured'), data ? (data.configured ? glyph('✅', 'Yes') : glyph('❌', 'No')) : '…'],
|
||||
[glyph('🔑', 'Steam API key'), data ? (data.has_api_key ? glyph('🔒', 'Stored (never shown)') : glyph('❌', 'Missing')) : '…'],
|
||||
[glyph('🆔', 'Steam ID'), h('span', { class: 'mono' }, data?.steam_id ?? '—')],
|
||||
]),
|
||||
{ aside: link(pageHref('/setup'), 'Open setup') },
|
||||
{ aside: link(pageHref('/setup'), glyph('✏️', 'Open setup')), icon: '🔧' },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
66
web/src/sections/system/textgate.guard.test.ts
Normal file
66
web/src/sections/system/textgate.guard.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
// Guard test (textgate-rework R6): the System and Setup pages paint glyphs,
|
||||
// numbers, ids, dates and the daemon's own journal, never chrome words.
|
||||
|
||||
import { screen, waitFor } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { makeSpec } from '../../test/fixtures'
|
||||
import { installEventSource, mount, stubApi } from '../../test/harness'
|
||||
import { visibleWords } from '../../test/visibleWords'
|
||||
import '../setup'
|
||||
import './backupsCard'
|
||||
import './daemonCard'
|
||||
import './serverCard'
|
||||
import './setupCard'
|
||||
|
||||
/** Content inside a painted string: dates as dateTime() paints them, sizes, ids. */
|
||||
const CONTENT = [/[A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2}( [AP]M)?/g, /\d[\d.,]* (B|KB|MB|GB)\b/g, /\d{8}-\d{6}/g]
|
||||
const chrome = (root: Element, allowed: string[] = []): string[] =>
|
||||
visibleWords(root, allowed).filter((w) => /[A-Za-z]{2,}/.test(CONTENT.reduce((s, re) => s.replace(re, ''), w)))
|
||||
|
||||
beforeEach(installEventSource)
|
||||
|
||||
describe('textgate guard: system', () => {
|
||||
it('server, setup, daemon and backup cards paint no words, stale and unreachable included', async () => {
|
||||
stubApi({
|
||||
'GET /api/server': { stale: true, started_at: '2026-10-10T10:00:00Z', version: '9.9.9' },
|
||||
'GET /api/setup': { configured: false, has_api_key: false, steam_id: null },
|
||||
'GET /api/daemon': { state: 'unreachable', started_at: null, pid: null, journal_tail: [], restart_available_at: null },
|
||||
'GET /api/backups': [{ id: '20261010-100000', created_at: '2026-10-10T10:00:00Z', reason: 'nightly', size_bytes: 2048 }],
|
||||
'GET /api/commands': [makeSpec('reset')],
|
||||
'GET /api/jobs': [],
|
||||
})
|
||||
const el = mount('<server-card></server-card><setup-card></setup-card><daemon-card></daemon-card><backups-card></backups-card>')
|
||||
await screen.findByText('20261010-100000')
|
||||
await screen.findByText('Daemon unreachable')
|
||||
await screen.findByText('Stale')
|
||||
// The recovery command is content kuhy runs.
|
||||
expect(chrome(el, ['systemctl status steam-backlog-enforcer'])).toEqual([])
|
||||
})
|
||||
|
||||
it('the restore dialog paints no words', async () => {
|
||||
stubApi({
|
||||
'GET /api/backups': [{ id: '20261010-100000', created_at: '2026-10-10T10:00:00Z', reason: 'before reset', size_bytes: 2048 }],
|
||||
'GET /api/commands': [makeSpec('reset')],
|
||||
'GET /api/jobs': [],
|
||||
})
|
||||
const el = mount('<backups-card></backups-card>')
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Restore…' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
// The phrase is the friction text kuhy types (settled decision 2).
|
||||
expect(chrome(el, ['restore backup 20261010-100000'])).toEqual([])
|
||||
expect(dialog).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the setup form paints no words, invalid values included', async () => {
|
||||
stubApi({ 'GET /api/setup': { configured: false, has_api_key: false, steam_id: null } })
|
||||
const el = mount('<setup-form></setup-form>')
|
||||
await screen.findByText('Not configured yet')
|
||||
await userEvent.type(screen.getByLabelText('Steam Web API key'), 'nothex')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save and verify' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Steam Web API key')).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
expect(chrome(el)).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user