Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
405 changes: 241 additions & 164 deletions apps/community/app/admin/system/page.tsx

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions apps/community/src/components/admin/system-run-details.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Translator } from '@meith/i18n'

import { systemRunDetail } from '@/view/system-run-detail'

const FIELD_KEYS: Readonly<Record<string, string>> = {
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 (
<dl className="mt-2 grid gap-x-6 gap-y-1 text-xs sm:grid-cols-2">
{fields.map((field) => (
<div key={JSON.stringify(field.path)} className="flex min-w-0 flex-wrap gap-x-2">
<dt className="text-muted-foreground">
{field.path.length === 0
? translator.t('adminSystem.result.value')
: field.path.map(label).join(' / ')}
</dt>
<dd className="min-w-0 break-words font-medium [overflow-wrap:anywhere]">
{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)}
</dd>
</div>
))}
</dl>
)
}
35 changes: 35 additions & 0 deletions apps/community/src/view/system-run-detail.test.ts
Original file line number Diff line number Diff line change
@@ -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 }])
}
})
})
33 changes: 33 additions & 0 deletions apps/community/src/view/system-run-detail.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion docs/customization/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/guides/operations/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
22 changes: 21 additions & 1 deletion e2e/admin-tabs-no-js.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()

Expand Down
47 changes: 43 additions & 4 deletions packages/i18n/src/catalogs/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
Expand Down Expand Up @@ -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}",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion themes/default/src/slots/post-bit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export function PostBit({ post, select, regions, copy }: PostBitSlotModel & { co
) : (
<div className={`flex-1 py-4 sm:py-5 ${BODY_X}`}>
<div
className="prose-md max-w-prose text-[0.9375rem] sm:text-base"
className="prose-md text-[0.9375rem] sm:text-base"
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
/>

Expand Down