From c1645603ed4b84a968e7c8c21610a2867bd79dda Mon Sep 17 00:00:00 2001 From: Jordan Harrison Date: Sun, 6 Sep 2026 19:47:46 +0100 Subject: [PATCH] fix(ui): widen thread posts and simplify system health --- apps/community/app/admin/system/page.tsx | 405 +++++++++++------- .../components/admin/system-run-details.tsx | 72 ++++ .../src/view/system-run-detail.test.ts | 35 ++ apps/community/src/view/system-run-detail.ts | 33 ++ docs/customization/themes.md | 2 +- docs/guides/operations/monitoring.md | 17 + e2e/admin-tabs-no-js.spec.ts | 22 +- packages/i18n/src/catalogs/en.json | 47 +- themes/default/src/slots/post-bit.tsx | 2 +- 9 files changed, 464 insertions(+), 171 deletions(-) create mode 100644 apps/community/src/components/admin/system-run-details.tsx create mode 100644 apps/community/src/view/system-run-detail.test.ts create mode 100644 apps/community/src/view/system-run-detail.ts diff --git a/apps/community/app/admin/system/page.tsx b/apps/community/app/admin/system/page.tsx index 7590b9040..5e31149d5 100644 --- a/apps/community/app/admin/system/page.tsx +++ b/apps/community/app/admin/system/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next' import type { TaskHealthStatus } from '@meith/tasks' -import { cn } from '@meith/ui' import { ApplyMigrationsForm, @@ -12,6 +11,7 @@ import { ReindexSearchForm, RetryJobForm, } from '@/components/admin/system-forms' +import { SystemRunDetails } from '@/components/admin/system-run-details' import { PANEL_CARD } from '@/components/shell/panel-list' import { PanelPage } from '@/components/shell/panel-page' import { adminPageContext } from '@/server/admin' @@ -35,9 +35,14 @@ export async function generateMetadata(): Promise { return { title: await tr('page.system-health') } } -export default async function AdminSystemPage() { +export default async function AdminSystemPage({ + searchParams, +}: { + searchParams: Promise<{ maintenance?: string }> +}) { if ((await adminPageContext()) === null) return null + const maintenanceOpen = (await searchParams).maintenance === '1' const now = new Date() const [translator, view, upgradeNotice] = await Promise.all([ getTranslator(), @@ -55,12 +60,19 @@ export default async function AdminSystemPage() { } const { mail, scheduler, volumes, legacyPasswordHashes } = view + const taskTitles = new Map(scheduler.tasks.map((task) => [task.key, task.titleKey])) + const taskCounts = Object.entries(TASK_STATUS_KEYS).map(([status, key]) => ({ + status, + label: translator.t(key), + count: scheduler.tasks.filter((task) => task.status === status).length, + })) return ( {scheduler.schedulerStopped && (
)} -
-

{translator.t('adminSystem.mail')}

-

- {mail.summary} - {!mail.sends && ( - - {' '} - {translator.t('adminSystem.mailDoesNotSend')} - - )}{' '} - {translator.t('adminSystem.activationMethod')} {mail.activationMethod}{' '} - {translator.t('adminSystem.configuredFrom')}{' '} - - {mail.source === 'environment' - ? translator.t('adminSystem.environment') - : translator.t('adminSystem.boardSettings')} - -

- {mail.source === 'board' && ( -

- {translator.t('adminSystem.changeMailBefore')}{' '} - - {translator.t('page.board-settings')} - - {translator.t('adminSystem.changeMailEnd')} -

- )} -

{translator.t('adminSystem.mailSchedule')}

-
- -
-

{await tr('page.scheduled-tasks')}

- {scheduler.tasks.length === 0 ? ( -

{translator.t('adminSystem.noTasks')}

- ) : ( -
    - {scheduler.tasks.map((task) => ( -
  • - - - {task.titleKey === undefined ? task.key : translator.t(task.titleKey)} - - {task.descriptionKey !== undefined && ( - - {translator.t(task.descriptionKey)} - - )} - - {task.key} ·{' '} - {translator.t('adminSystem.taskInterval', { seconds: task.intervalSeconds })} ·{' '} - {task.lastRunAt === null - ? translator.t('adminSystem.neverRun') - : translator.t('adminSystem.taskLastRun', { - time: formatTime(task.lastRunAt, now, translator).label, - })} - {task.consecutiveFailures > 0 && - ` · ${translator.t('adminSystem.taskFailures', { - count: task.consecutiveFailures, - })}`} - - - - {translator.t(TASK_STATUS_KEYS[task.status])} - -
  • - ))} -
- )} -
- -
-

{await tr('page.recent-runs')}

- {view.runs.length === 0 ? ( -

{await tr('page.nothing-has-run-yet')}

- ) : ( -
    - {view.runs.map((run, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: the tiebreaker between two runs of one task recorded at the same instant -
  • - {run.taskKey}{' '} - - {run.succeeded - ? translator.t('adminSystem.taskRunOk') - : translator.t('adminSystem.taskRunFailed')} - {run.durationMs !== null && ` · ${run.durationMs}ms`} ·{' '} - - {run.detail !== null && ` · ${run.detail}`} - - {run.error !== null && ( - {run.error} - )} -
  • - ))} -
- )} -
-

{translator.t('adminSystem.volumes')} @@ -255,65 +157,201 @@ export default async function AdminSystemPage() {

- {legacyPasswordHashes > 0 && ( +
-

- {translator.t('adminSystem.legacyPasswords')} -

+

{translator.t('adminSystem.mail')}

- {translator.t('adminSystem.legacyPasswordsCount', { count: legacyPasswordHashes })} + {mail.summary} + {!mail.sends && ( + + {' '} + {translator.t('adminSystem.mailDoesNotSend')} + + )}{' '} + {translator.t('adminSystem.activationMethod')} {mail.activationMethod}{' '} + {translator.t('adminSystem.configuredFrom')}{' '} + + {mail.source === 'environment' + ? translator.t('adminSystem.environment') + : translator.t('adminSystem.boardSettings')} +

+ {mail.source === 'board' && ( +

+ {translator.t('adminSystem.changeMailBefore')}{' '} + + {translator.t('page.board-settings')} + + {translator.t('adminSystem.changeMailEnd')} +

+ )}

- {translator.t('adminSystem.legacyPasswordsHint')} + {translator.t('adminSystem.mailSchedule')}

- )} -
-

- {translator.t('adminSystem.migrations')} -

-

- {translator.t('adminSystem.runningVersion')} {CODE_VERSION}. -

- {upgradeNotice === null ? ( -

- {translator.t('adminSystem.migrationsUpToDate')} +

+

+ {translator.t('adminSystem.migrations')} +

+

+ {translator.t('adminSystem.runningVersion')} {CODE_VERSION}.

- ) : ( - <> -

{upgradeNotice}

+ {upgradeNotice === null ? (

- {translator.t('adminSystem.migrationsHint')} + {translator.t('adminSystem.migrationsUpToDate')}

- - - )} -
+ ) : ( + <> +

{upgradeNotice}

+

+ {translator.t('adminSystem.migrationsHint')} +

+ + + )} +
+
-

- {translator.t('adminSystem.recount')} -

-

{translator.t('adminSystem.recountHint')}

- {view.recount.length > 0 && ( -
    - {view.recount.map((row) => ( -
  • - {translator.t('adminSystem.recountProgress', { - id: row.id, - phase: row.phase, - cursor: row.cursor, - passes: row.passes, - corrected: row.corrected, - })} +

    {await tr('page.scheduled-tasks')}

    +

    + {translator.t('adminSystem.taskCount', { count: scheduler.tasks.length })} +

    +
      + {taskCounts + .filter(({ count }) => count > 0) + .map(({ status, label, count }) => ( +
    • + {translator.t('adminSystem.tasksInState', { count, status: label })}
    • ))} -
    - )} - +
+
0 || scheduler.failing > 0}> + + {translator.t('adminSystem.showTasks')} + + {scheduler.tasks.length === 0 ? ( +

{translator.t('adminSystem.noTasks')}

+ ) : ( +
    + {scheduler.tasks.map((task) => ( +
  • +
    + + {task.titleKey === undefined ? task.key : translator.t(task.titleKey)} + + + {translator.t('adminSystem.taskInterval', { seconds: task.intervalSeconds })}{' '} + ·{' '} + {task.lastRunAt === null + ? translator.t('adminSystem.neverRun') + : translator.t('adminSystem.taskLastRun', { + time: formatTime(task.lastRunAt, now, translator).label, + })} + {task.consecutiveFailures > 0 && + ` · ${translator.t('adminSystem.taskFailures', { + count: task.consecutiveFailures, + })}`} + +
    + + {translator.t('adminSystem.taskDetails')} + +
    + {task.key} + {task.descriptionKey !== undefined && ( +

    {translator.t(task.descriptionKey)}

    + )} +
    +
    +
    + + {translator.t(TASK_STATUS_KEYS[task.status])} + +
  • + ))} +
+ )} +
+
+

{await tr('page.recent-runs')}

+
!run.succeeded)}> + + {translator.t('adminSystem.showRuns')} + + {translator.t('adminSystem.runCount', { count: view.runs.length })} + + + {view.runs.length === 0 ? ( +

{await tr('page.nothing-has-run-yet')}

+ ) : ( +
    + {view.runs.map((run, index) => ( +
  • + + {taskTitles.get(run.taskKey) === undefined + ? run.taskKey + : translator.t(taskTitles.get(run.taskKey)!)} + {' '} + + {run.succeeded + ? translator.t('adminSystem.taskRunOk') + : translator.t('adminSystem.taskRunFailed')} + {run.durationMs !== null && ` · ${run.durationMs}ms`} ·{' '} + + + + {run.error !== null && ( + {run.error} + )} +
  • + ))} +
+ )} +
+
+ + {legacyPasswordHashes > 0 && ( +
+

+ {translator.t('adminSystem.legacyPasswords')} +

+

+ {translator.t('adminSystem.legacyPasswordsCount', { count: legacyPasswordHashes })} +

+

+ {translator.t('adminSystem.legacyPasswordsHint')} +

+
+ )} +

{await tr('page.search-index')}

@@ -332,18 +370,57 @@ export default async function AdminSystemPage() {

-
+

{translator.t('adminSystem.maintenance')}

-

- {translator.t('adminSystem.maintenanceHint')} -

+ + {translator.t( + maintenanceOpen ? 'adminSystem.closeMaintenance' : 'adminSystem.openMaintenance', + )} + + {maintenanceOpen && ( +
+
+

{translator.t('adminSystem.recount')}

+

+ {translator.t('adminSystem.recountHint')} +

+ {view.recount.length > 0 && ( +
    + {view.recount.map((row) => ( +
  • + {translator.t('adminSystem.recountProgress', { + id: row.id, + phase: row.phase, + cursor: row.cursor, + passes: row.passes, + corrected: row.corrected, + })} +
  • + ))} +
+ )} + +
+ +

+ {translator.t('adminSystem.maintenanceHint')} +

- - - - + + + + +
+ )}
) diff --git a/apps/community/src/components/admin/system-run-details.tsx b/apps/community/src/components/admin/system-run-details.tsx new file mode 100644 index 000000000..5979586e6 --- /dev/null +++ b/apps/community/src/components/admin/system-run-details.tsx @@ -0,0 +1,72 @@ +import type { Translator } from '@meith/i18n' + +import { systemRunDetail } from '@/view/system-run-detail' + +const FIELD_KEYS: Readonly> = { + attempted: 'adminSystem.result.attempted', + delivered: 'adminSystem.result.delivered', + retried: 'adminSystem.result.retried', + dead: 'adminSystem.result.dead', + relayed: 'adminSystem.result.relayed', + processed: 'adminSystem.result.processed', + removed: 'adminSystem.result.removed', + corrected: 'adminSystem.result.corrected', + flushed: 'adminSystem.result.flushed', + rendered: 'adminSystem.result.rendered', + indexed: 'adminSystem.result.indexed', + ok: 'adminSystem.result.ok', + listingCount: 'adminSystem.result.listingCount', + notified: 'adminSystem.result.notified', + promoted: 'adminSystem.result.promoted', + lifted: 'adminSystem.result.lifted', + expired: 'adminSystem.result.expired', + deleted: 'adminSystem.result.deleted', + failed: 'adminSystem.result.failed', + memberCount: 'adminSystem.result.memberCount', + online: 'adminSystem.result.online', + record: 'adminSystem.result.record', + ran: 'adminSystem.result.ran', + trigger: 'adminSystem.result.trigger', + bundle: 'adminSystem.result.bundle', + status: 'adminSystem.result.status', + skipped: 'adminSystem.result.skipped', +} + +export function SystemRunDetails({ + detail, + translator, +}: { + detail: string | null + translator: Translator +}) { + const fields = systemRunDetail(detail) + if (fields.length === 0) return null + + const label = (key: string) => { + const message = Object.hasOwn(FIELD_KEYS, key) ? FIELD_KEYS[key] : undefined + if (message !== undefined) return translator.t(message) + const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ') + return words.charAt(0).toUpperCase() + words.slice(1) + } + + return ( +
+ {fields.map((field) => ( +
+
+ {field.path.length === 0 + ? translator.t('adminSystem.result.value') + : field.path.map(label).join(' / ')} +
+
+ {field.value === null + ? translator.t('adminSystem.result.empty') + : typeof field.value === 'boolean' + ? translator.t(field.value ? 'adminSystem.result.yes' : 'adminSystem.result.no') + : String(field.value)} +
+
+ ))} +
+ ) +} diff --git a/apps/community/src/view/system-run-detail.test.ts b/apps/community/src/view/system-run-detail.test.ts new file mode 100644 index 000000000..c7892b7d9 --- /dev/null +++ b/apps/community/src/view/system-run-detail.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { systemRunDetail } from './system-run-detail' + +describe('system run details', () => { + it('keeps zero counts and boolean results', () => { + expect(systemRunDetail('{"processed":0,"ok":true}')).toEqual([ + { path: ['processed'], value: 0 }, + { path: ['ok'], value: true }, + ]) + }) + + it('turns nested plugin results into fields in their original order', () => { + expect(systemRunDetail('{"delivery":{"failed":2},"files":["one","two"]}')).toEqual([ + { path: ['delivery', 'failed'], value: 2 }, + { path: ['files', '1'], value: 'one' }, + { path: ['files', '2'], value: 'two' }, + ]) + }) + + it('preserves text and malformed legacy details without throwing', () => { + for (const detail of ['Skipped while disabled', '{incomplete']) { + expect(systemRunDetail(detail)).toEqual([{ path: [], value: detail }]) + } + expect(systemRunDetail('"Skipped"')).toEqual([{ path: [], value: 'Skipped' }]) + }) + + it('handles absent details and empty structured results', () => { + expect(systemRunDetail(null)).toEqual([]) + expect(systemRunDetail(' ')).toEqual([]) + for (const detail of ['null', '{}', '[]']) { + expect(systemRunDetail(detail)).toEqual([{ path: [], value: null }]) + } + }) +}) diff --git a/apps/community/src/view/system-run-detail.ts b/apps/community/src/view/system-run-detail.ts new file mode 100644 index 000000000..65f19b308 --- /dev/null +++ b/apps/community/src/view/system-run-detail.ts @@ -0,0 +1,33 @@ +export interface RunDetailField { + readonly path: readonly string[] + readonly value: string | number | boolean | null +} + +export function systemRunDetail(detail: string | null): readonly RunDetailField[] { + if (detail === null || detail.trim() === '') return [] + + let parsed: unknown + try { + parsed = JSON.parse(detail) + } catch { + return [{ path: [], value: detail }] + } + + const fields: RunDetailField[] = [] + const pending = [{ path: [] as string[], value: parsed }] + while (pending.length > 0) { + const { path, value } = pending.pop()! + if (value !== null && typeof value === 'object') { + const entries = Array.isArray(value) + ? value.map((item, index) => [String(index + 1), item] as const) + : Object.entries(value) + if (entries.length === 0) fields.push({ path, value: null }) + for (const [key, item] of entries.reverse()) { + pending.push({ path: [...path, key], value: item }) + } + } else { + fields.push({ path, value: value as RunDetailField['value'] }) + } + } + return fields +} diff --git a/docs/customization/themes.md b/docs/customization/themes.md index 56161ff3e..a733df3da 100644 --- a/docs/customization/themes.md +++ b/docs/customization/themes.md @@ -620,7 +620,7 @@ recipe into a slot when the shared primitive already expresses it. | Surfaces | Page uses `background`, cards use `card`, and card headers use a quieter `surface` band. Cards share token-derived corners, borders and elevation. | | Controls | Standard buttons, inputs and selects are at least 40px high; large actions are 44px. Compact controls are 32px on a fine pointer. Shared controls have a 44px minimum touch height. | | States | Primary actions use `primary` and `primary-hover`; keyboard focus uses an explicit 2px `ring` outline; invalid fields use `destructive` for border and focus. Disabled controls retain their label and reduce emphasis. | -| Reading | Default post bodies stop at a prose measure, while attachments and post actions retain the available width. Long titles wrap without widening the page. | +| Reading | Default post bodies fill the available content width inside the post padding, matching attachments and post actions. Long titles wrap without widening the page. | `PageHeader`, `PageHeaderContent`, `PageTitle`, `PageDescription` and `PageHeaderActions` compose a page introduction. Content and actions wrap diff --git a/docs/guides/operations/monitoring.md b/docs/guides/operations/monitoring.md index 70b2f28a3..c06e829a2 100644 --- a/docs/guides/operations/monitoring.md +++ b/docs/guides/operations/monitoring.md @@ -5,6 +5,23 @@ health endpoints, scheduler checks, optional metrics, and logs. The container examples assume a Compose deployment; the HTTP tick section also applies to deployments without a worker process. +## Admin System page + +The System page starts with alerts, board totals, mail readiness and the running +version. Scheduled work shows a count for each task status; expand **View +scheduled tasks** for individual tasks, then **Task details** for a task's +technical identifier and explanation. Stale or failing tasks expand the list +automatically. + +**View recent runs** shows task names, outcomes, timing and readable result +fields instead of JSON. Nested plugin results retain their field paths, and +plain-text results remain readable. The history opens automatically when a +recent run failed, with its error visible. **Open maintenance tools** contains +recount, session and token cleanup, cache clearing and job retry. It stays open +after submitting an action so its result remains visible. Search index +progress and its backfill action stay visible. These disclosures work without +JavaScript. + ## Liveness and readiness Two endpoints answer different questions, both unauthenticated (neither returns anything an anonymous visitor could not already infer from the board being up): diff --git a/e2e/admin-tabs-no-js.spec.ts b/e2e/admin-tabs-no-js.spec.ts index ca9f27e5d..005971194 100644 --- a/e2e/admin-tabs-no-js.spec.ts +++ b/e2e/admin-tabs-no-js.spec.ts @@ -743,13 +743,32 @@ test('the plugins screen names what is installed and how installing works', asyn test('the system screen reports the scheduler, the volumes and its own sweeps', async ({ page, request, -}) => { +}, testInfo) => { await enterAdminPanel(page) await runTick(request) await page.goto('/admin/system') + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 1000 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath(`system-${width}.png`), fullPage: true }) + } + await page.setViewportSize({ width: 1440, height: 1000 }) + + const runs = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Recent runs', exact: true }) }) + if ((await runs.locator('details').getAttribute('open')) === null) { + await runs.locator('summary').click() + } + await expect(runs.locator('dl').first()).toBeVisible() + await expect(runs).not.toContainText('{"') + await expect(runs).not.toContainText('[object Object]') + await page.screenshot({ path: testInfo.outputPath('system-runs.png'), fullPage: true }) + const tasks = page.locator('section').filter({ hasText: 'Scheduled tasks' }).last() + await tasks.getByText('View scheduled tasks', { exact: true }).click() await expect(tasks.locator('li').filter({ hasText: 'queue.drain' })).toContainText('every 60s') await expect(tasks.locator('li').filter({ hasText: 'search.reindex' })).toBeVisible() await expect( @@ -761,6 +780,7 @@ test('the system screen reports the scheduler, the volumes and its own sweeps', await expect(page.getByText(/\d+ posts/)).toBeVisible() await expect(page.getByText(/\d+ jobs waiting/)).toBeVisible() + await page.getByText('Open maintenance tools', { exact: true }).click() await page.getByRole('button', { name: /Prune \d+ expired sessions?/ }).click() await expect(page.getByText(/\d+ session rows removed\./)).toBeVisible() diff --git a/packages/i18n/src/catalogs/en.json b/packages/i18n/src/catalogs/en.json index 6191e4077..253a122c9 100644 --- a/packages/i18n/src/catalogs/en.json +++ b/packages/i18n/src/catalogs/en.json @@ -875,6 +875,7 @@ "adminSystem.boardSettings": "board settings", "adminSystem.changeMailBefore": "Change it — and send a test message to prove it works — on the", "adminSystem.changeMailEnd": ".", + "adminSystem.closeMaintenance": "Hide maintenance tools", "adminSystem.configuredFrom": " · configured from", "adminSystem.deadLettered": "{count} dead-lettered", "adminSystem.environment": "the environment", @@ -886,29 +887,64 @@ "adminSystem.legacyPasswordsHint": "Each one upgrades to the board’s own hash automatically the next time that member signs in successfully. Nothing to do here — this is a gauge of how much of the migration is left, not a problem to fix by hand.", "adminSystem.mail": "Mail", "adminSystem.mailDoesNotSend": "— nothing this board sends reaches anybody.", - "adminSystem.mailSchedule": "Notification and mass mail leave on the tick above, so a stopped scheduler is also a board that sends none of them. Verification and password-reset links are sent as the request happens and do not wait for it.", + "adminSystem.mailSchedule": "Scheduled mail depends on the scheduler. Verification and password-reset emails are sent immediately.", "adminSystem.maintenance": "Maintenance", - "adminSystem.maintenanceHint": "Each of these is bounded to one batch. Nothing here destroys anything an operator would want back: expired sessions no longer authenticate anybody, expired tokens can no longer be used, and a cleared cache is a copy of data that still exists.", + "adminSystem.maintenanceHint": "Remove expired sessions and tokens, clear cached forum data, or retry a failed job. Each action runs one batch.", "adminSystem.members": "{count, plural, one {# member} other {# members}}", "adminSystem.migrations": "Version & migrations", "adminSystem.migrationsHint": "This applies the pending plugin migrations here, so a plugin whose setup a deploy left undone is finished from the browser — the same ones meith upgrade would run, without the SSH. Core schema migrations belong to the deploy step (meith migrate) and are not run from here. You will be asked for your password.", "adminSystem.migrationsUpToDate": "The database matches this version. There is nothing to apply.", "adminSystem.neverRun": "never run", "adminSystem.noTasks": "No tasks are registered. A build registers a task only when it has a worker that can genuinely do the work, so an absent one means the feature behind it is not wired up on this deployment.", + "adminSystem.openMaintenance": "Open maintenance tools", "adminSystem.posts": "{count, plural, one {# post} other {# posts}}", "adminSystem.queuedJobs": "{count, plural, one {# job waiting} other {# jobs waiting}}", "adminSystem.recount": "Recount & rebuild", - "adminSystem.recountHint": "Counters are denormalised, so they can drift. The recount walks the content and corrects them in bounded batches, keeping its phase and cursor in the database — so it resumes where it stopped rather than starting over, which is what makes it finish at all on a large board.", + "adminSystem.recountHint": "Recalculate forum, thread and member counters. Each batch resumes where the previous one stopped.", "adminSystem.recountProgress": "{id}: phase {phase}, cursor {cursor}, {passes, plural, one {# complete pass} other {# complete passes}}, {corrected} corrected", + "adminSystem.result.attempted": "Attempted", + "adminSystem.result.bundle": "Backup file", + "adminSystem.result.corrected": "Corrected", + "adminSystem.result.dead": "Failed permanently", + "adminSystem.result.deleted": "Deleted", + "adminSystem.result.delivered": "Delivered", + "adminSystem.result.empty": "None", + "adminSystem.result.expired": "Expired", + "adminSystem.result.failed": "Failed", + "adminSystem.result.flushed": "Views saved", + "adminSystem.result.indexed": "Posts indexed", + "adminSystem.result.lifted": "Bans lifted", + "adminSystem.result.listingCount": "Listings", + "adminSystem.result.memberCount": "Members", + "adminSystem.result.no": "No", + "adminSystem.result.notified": "Notified", + "adminSystem.result.ok": "Successful", + "adminSystem.result.online": "Online", + "adminSystem.result.processed": "Processed", + "adminSystem.result.promoted": "Promoted", + "adminSystem.result.ran": "Backups run", + "adminSystem.result.record": "New online record", + "adminSystem.result.relayed": "Relayed", + "adminSystem.result.removed": "Removed", + "adminSystem.result.rendered": "Posts rendered", + "adminSystem.result.retried": "Retried", + "adminSystem.result.skipped": "Skipped", + "adminSystem.result.status": "Status", + "adminSystem.result.trigger": "Trigger", + "adminSystem.result.value": "Result", + "adminSystem.result.yes": "Yes", + "adminSystem.runCount": "{count, plural, one {# recent run} other {# recent runs}}", "adminSystem.runningVersion": "This board is running", "adminSystem.sample": "This board is running on in-memory sample data, so it has no scheduler and nothing to maintain.", "adminSystem.schedulerStopped": "Nothing is broken and nothing is lost — the tasks are written to catch up, so they will work through the backlog once it runs again. Check that whatever invokes the scheduled endpoint is still configured and still authorised.", "adminSystem.schedulerStoppedAfter": ", digests and notification emails are not sent, counters drift, uploads that failed to process are not retried, and queued mail sits in the queue.", "adminSystem.schedulerStoppedBefore": "Every task is overdue, which means the tick is not firing at all. While this is true:", "adminSystem.schedulerStoppedStrong": "bans do not expire", - "adminSystem.searchIndexHint": "A post is indexed when it is written or edited, so this is only ever a backfill — an existing board adopting search, or one whose index was invalidated. It resumes by construction: the batch is “posts with no index entry”, a set that only shrinks, so an interrupted run costs nothing and a repeated one does nothing.", + "adminSystem.searchIndexHint": "Make older posts searchable. New and edited posts are indexed automatically.", "adminSystem.searchIndexIndexed": "{count} indexed", "adminSystem.searchIndexPending": "{count} not yet searchable", + "adminSystem.showRuns": "View recent runs", + "adminSystem.showTasks": "View scheduled tasks", "adminSystem.task.attachmentsSweep.description": "Deletes objects in the file store that nothing owns, and fails uploads whose re-encoding never finished. An object key is recorded before its bytes are written and forgotten when a row takes ownership, so what this collects is exactly what a crash between those two steps left behind — a question that is an indexed query here and a full bucket listing anywhere else. Purely subtractive, and bounded by a grace period, so an upload in flight is never collected out from under itself.", "adminSystem.task.attachmentsSweep.title": "Collect abandoned attachment files", "adminSystem.task.authEventsPrune.description": "Drops sign-in activity older than the retention the board is set to keep. Does nothing at all while that setting is 0, which is the default: the log is an audit trail, and one that expires without being asked to is worse than one that grows.", @@ -955,6 +991,8 @@ "adminSystem.task.warningsExpire.title": "Expire warnings", "adminSystem.task.webhooksDeliver.description": "Sends pending webhook deliveries to their subscribers, then records each verdict: delivered, retried with backoff, or dead-lettered.", "adminSystem.task.webhooksDeliver.title": "Deliver queued webhooks", + "adminSystem.taskCount": "{count, plural, one {# scheduled task} other {# scheduled tasks}}", + "adminSystem.taskDetails": "Task details", "adminSystem.taskFailures": "{count, plural, one {# failure in a row} other {# failures in a row}}", "adminSystem.taskInterval": "every {seconds}s", "adminSystem.taskLastRun": "last ran {time}", @@ -968,6 +1006,7 @@ "adminSystem.taskStatus.running": "running", "adminSystem.taskStatus.stale": "stale", "adminSystem.tasksFailing": "{count, plural, one {# task is failing repeatedly — running, and losing. The log below says why.} other {# tasks are failing repeatedly — running, and losing. The log below says why.}}", + "adminSystem.tasksInState": "{count} {status}", "adminSystem.tasksOverdue": "{count, plural, one {# task is overdue by several of its own intervals.} other {# tasks are overdue by several of their own intervals.}}", "adminSystem.threads": "{count, plural, one {# thread} other {# threads}}", "adminSystem.volumes": "Volumes", diff --git a/themes/default/src/slots/post-bit.tsx b/themes/default/src/slots/post-bit.tsx index 0a6a7c53e..a81b5b63d 100644 --- a/themes/default/src/slots/post-bit.tsx +++ b/themes/default/src/slots/post-bit.tsx @@ -237,7 +237,7 @@ export function PostBit({ post, select, regions, copy }: PostBitSlotModel & { co ) : (