@meith/web 0.36.1 → 0.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
3
  import type { TaskHealthStatus } from '@meith/tasks'
4
- import { cn } from '@meith/ui'
5
4
 
6
5
  import {
7
6
  ApplyMigrationsForm,
@@ -12,6 +11,7 @@ import {
12
11
  ReindexSearchForm,
13
12
  RetryJobForm,
14
13
  } from '@/components/admin/system-forms'
14
+ import { SystemRunDetails } from '@/components/admin/system-run-details'
15
15
  import { PANEL_CARD } from '@/components/shell/panel-list'
16
16
  import { PanelPage } from '@/components/shell/panel-page'
17
17
  import { adminPageContext } from '@/server/admin'
@@ -35,9 +35,14 @@ export async function generateMetadata(): Promise<Metadata> {
35
35
  return { title: await tr('page.system-health') }
36
36
  }
37
37
 
38
- export default async function AdminSystemPage() {
38
+ export default async function AdminSystemPage({
39
+ searchParams,
40
+ }: {
41
+ searchParams: Promise<{ maintenance?: string }>
42
+ }) {
39
43
  if ((await adminPageContext()) === null) return null
40
44
 
45
+ const maintenanceOpen = (await searchParams).maintenance === '1'
41
46
  const now = new Date()
42
47
  const [translator, view, upgradeNotice] = await Promise.all([
43
48
  getTranslator(),
@@ -55,12 +60,19 @@ export default async function AdminSystemPage() {
55
60
  }
56
61
 
57
62
  const { mail, scheduler, volumes, legacyPasswordHashes } = view
63
+ const taskTitles = new Map(scheduler.tasks.map((task) => [task.key, task.titleKey]))
64
+ const taskCounts = Object.entries(TASK_STATUS_KEYS).map(([status, key]) => ({
65
+ status,
66
+ label: translator.t(key),
67
+ count: scheduler.tasks.filter((task) => task.status === status).length,
68
+ }))
58
69
 
59
70
  return (
60
71
  <PanelPage
61
72
  title={await tr('page.system-health')}
62
73
  lede={await tr('page.what-board-does-schedule-whether')}
63
- gap="loose"
74
+ width="wide"
75
+ gap="normal"
64
76
  >
65
77
  {scheduler.schedulerStopped && (
66
78
  <section
@@ -129,116 +141,6 @@ export default async function AdminSystemPage() {
129
141
  </section>
130
142
  )}
131
143
 
132
- <section className={PANEL_CARD}>
133
- <h2 className="font-heading text-lg font-semibold">{translator.t('adminSystem.mail')}</h2>
134
- <p className="text-sm">
135
- {mail.summary}
136
- {!mail.sends && (
137
- <span className="text-muted-foreground">
138
- {' '}
139
- {translator.t('adminSystem.mailDoesNotSend')}
140
- </span>
141
- )}{' '}
142
- {translator.t('adminSystem.activationMethod')} <code>{mail.activationMethod}</code>{' '}
143
- {translator.t('adminSystem.configuredFrom')}{' '}
144
- <code>
145
- {mail.source === 'environment'
146
- ? translator.t('adminSystem.environment')
147
- : translator.t('adminSystem.boardSettings')}
148
- </code>
149
- </p>
150
- {mail.source === 'board' && (
151
- <p className="text-sm text-muted-foreground">
152
- {translator.t('adminSystem.changeMailBefore')}{' '}
153
- <a href="/admin/settings?group=mail" className="underline">
154
- {translator.t('page.board-settings')}
155
- </a>
156
- {translator.t('adminSystem.changeMailEnd')}
157
- </p>
158
- )}
159
- <p className="text-sm text-muted-foreground">{translator.t('adminSystem.mailSchedule')}</p>
160
- </section>
161
-
162
- <section className={PANEL_CARD}>
163
- <h2 className="font-heading text-lg font-semibold">{await tr('page.scheduled-tasks')}</h2>
164
- {scheduler.tasks.length === 0 ? (
165
- <p className="text-sm text-muted-foreground">{translator.t('adminSystem.noTasks')}</p>
166
- ) : (
167
- <ul className="flex flex-col divide-y divide-border">
168
- {scheduler.tasks.map((task) => (
169
- <li
170
- key={task.key}
171
- className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-3 first:pt-0 last:pb-0"
172
- >
173
- <span className="flex min-w-0 flex-col">
174
- <span className="truncate text-sm font-medium">
175
- {task.titleKey === undefined ? task.key : translator.t(task.titleKey)}
176
- </span>
177
- {task.descriptionKey !== undefined && (
178
- <span className="text-xs text-muted-foreground">
179
- {translator.t(task.descriptionKey)}
180
- </span>
181
- )}
182
- <span className="truncate text-xs text-muted-foreground">
183
- <code>{task.key}</code> ·{' '}
184
- {translator.t('adminSystem.taskInterval', { seconds: task.intervalSeconds })} ·{' '}
185
- {task.lastRunAt === null
186
- ? translator.t('adminSystem.neverRun')
187
- : translator.t('adminSystem.taskLastRun', {
188
- time: formatTime(task.lastRunAt, now, translator).label,
189
- })}
190
- {task.consecutiveFailures > 0 &&
191
- ` · ${translator.t('adminSystem.taskFailures', {
192
- count: task.consecutiveFailures,
193
- })}`}
194
- </span>
195
- </span>
196
- <span
197
- className={
198
- task.status === 'healthy' ||
199
- task.status === 'running' ||
200
- task.status === 'disabled'
201
- ? 'shrink-0 text-xs text-muted-foreground'
202
- : 'shrink-0 text-xs font-medium text-destructive'
203
- }
204
- >
205
- {translator.t(TASK_STATUS_KEYS[task.status])}
206
- </span>
207
- </li>
208
- ))}
209
- </ul>
210
- )}
211
- </section>
212
-
213
- <section className={PANEL_CARD}>
214
- <h2 className="font-heading text-lg font-semibold">{await tr('page.recent-runs')}</h2>
215
- {view.runs.length === 0 ? (
216
- <p className="text-sm text-muted-foreground">{await tr('page.nothing-has-run-yet')}</p>
217
- ) : (
218
- <ul className="flex flex-col gap-1 text-sm">
219
- {view.runs.map((run, index) => (
220
- // biome-ignore lint/suspicious/noArrayIndexKey: the tiebreaker between two runs of one task recorded at the same instant
221
- <li key={`${run.taskKey}:${run.ranAt.toISOString()}:${index}`}>
222
- <code className="text-xs">{run.taskKey}</code>{' '}
223
- <span className="text-muted-foreground">
224
- {run.succeeded
225
- ? translator.t('adminSystem.taskRunOk')
226
- : translator.t('adminSystem.taskRunFailed')}
227
- {run.durationMs !== null && ` · ${run.durationMs}ms`} ·{' '}
228
- <time dateTime={run.ranAt.toISOString()}>
229
- {formatTime(run.ranAt, now, translator).label}
230
- </time>
231
- {run.detail !== null && ` · ${run.detail}`}
232
- </span>
233
- {run.error !== null && (
234
- <span className="block text-xs text-destructive">{run.error}</span>
235
- )}
236
- </li>
237
- ))}
238
- </ul>
239
- )}
240
- </section>
241
-
242
144
  <section className={PANEL_CARD}>
243
145
  <h2 className="font-heading text-lg font-semibold">
244
146
  {translator.t('adminSystem.volumes')}
@@ -255,65 +157,201 @@ export default async function AdminSystemPage() {
255
157
  </ul>
256
158
  </section>
257
159
 
258
- {legacyPasswordHashes > 0 && (
160
+ <div className="grid items-start gap-4 xl:grid-cols-2">
259
161
  <section className={PANEL_CARD}>
260
- <h2 className="font-heading text-lg font-semibold">
261
- {translator.t('adminSystem.legacyPasswords')}
262
- </h2>
162
+ <h2 className="font-heading text-lg font-semibold">{translator.t('adminSystem.mail')}</h2>
263
163
  <p className="text-sm">
264
- {translator.t('adminSystem.legacyPasswordsCount', { count: legacyPasswordHashes })}
164
+ {mail.summary}
165
+ {!mail.sends && (
166
+ <span className="text-muted-foreground">
167
+ {' '}
168
+ {translator.t('adminSystem.mailDoesNotSend')}
169
+ </span>
170
+ )}{' '}
171
+ {translator.t('adminSystem.activationMethod')} <code>{mail.activationMethod}</code>{' '}
172
+ {translator.t('adminSystem.configuredFrom')}{' '}
173
+ <code>
174
+ {mail.source === 'environment'
175
+ ? translator.t('adminSystem.environment')
176
+ : translator.t('adminSystem.boardSettings')}
177
+ </code>
265
178
  </p>
179
+ {mail.source === 'board' && (
180
+ <p className="text-sm text-muted-foreground">
181
+ {translator.t('adminSystem.changeMailBefore')}{' '}
182
+ <a href="/admin/settings?group=mail" className="underline">
183
+ {translator.t('page.board-settings')}
184
+ </a>
185
+ {translator.t('adminSystem.changeMailEnd')}
186
+ </p>
187
+ )}
266
188
  <p className="text-sm text-muted-foreground">
267
- {translator.t('adminSystem.legacyPasswordsHint')}
189
+ {translator.t('adminSystem.mailSchedule')}
268
190
  </p>
269
191
  </section>
270
- )}
271
192
 
272
- <section className={PANEL_CARD}>
273
- <h2 className="font-heading text-lg font-semibold">
274
- {translator.t('adminSystem.migrations')}
275
- </h2>
276
- <p className="text-sm">
277
- {translator.t('adminSystem.runningVersion')} <code>{CODE_VERSION}</code>.
278
- </p>
279
- {upgradeNotice === null ? (
280
- <p className="text-sm text-muted-foreground">
281
- {translator.t('adminSystem.migrationsUpToDate')}
193
+ <section className={PANEL_CARD}>
194
+ <h2 className="font-heading text-lg font-semibold">
195
+ {translator.t('adminSystem.migrations')}
196
+ </h2>
197
+ <p className="text-sm">
198
+ {translator.t('adminSystem.runningVersion')} <code>{CODE_VERSION}</code>.
282
199
  </p>
283
- ) : (
284
- <>
285
- <p className="text-sm text-destructive">{upgradeNotice}</p>
200
+ {upgradeNotice === null ? (
286
201
  <p className="text-sm text-muted-foreground">
287
- {translator.t('adminSystem.migrationsHint')}
202
+ {translator.t('adminSystem.migrationsUpToDate')}
288
203
  </p>
289
- <ApplyMigrationsForm copy={copy} />
290
- </>
291
- )}
292
- </section>
204
+ ) : (
205
+ <>
206
+ <p className="text-sm text-destructive">{upgradeNotice}</p>
207
+ <p className="text-sm text-muted-foreground">
208
+ {translator.t('adminSystem.migrationsHint')}
209
+ </p>
210
+ <ApplyMigrationsForm copy={copy} />
211
+ </>
212
+ )}
213
+ </section>
214
+ </div>
293
215
 
294
216
  <section className={PANEL_CARD}>
295
- <h2 className="font-heading text-lg font-semibold">
296
- {translator.t('adminSystem.recount')}
297
- </h2>
298
- <p className="text-sm text-muted-foreground">{translator.t('adminSystem.recountHint')}</p>
299
- {view.recount.length > 0 && (
300
- <ul className="flex flex-col gap-1 text-xs text-muted-foreground">
301
- {view.recount.map((row) => (
302
- <li key={row.id}>
303
- {translator.t('adminSystem.recountProgress', {
304
- id: row.id,
305
- phase: row.phase,
306
- cursor: row.cursor,
307
- passes: row.passes,
308
- corrected: row.corrected,
309
- })}
217
+ <h2 className="font-heading text-lg font-semibold">{await tr('page.scheduled-tasks')}</h2>
218
+ <p className="text-sm text-muted-foreground">
219
+ {translator.t('adminSystem.taskCount', { count: scheduler.tasks.length })}
220
+ </p>
221
+ <ul className="flex flex-wrap gap-x-4 gap-y-2 text-sm">
222
+ {taskCounts
223
+ .filter(({ count }) => count > 0)
224
+ .map(({ status, label, count }) => (
225
+ <li
226
+ key={status}
227
+ className={
228
+ status === 'stale' || status === 'failing'
229
+ ? 'font-medium text-destructive'
230
+ : 'text-muted-foreground'
231
+ }
232
+ >
233
+ {translator.t('adminSystem.tasksInState', { count, status: label })}
310
234
  </li>
311
235
  ))}
312
- </ul>
313
- )}
314
- <RecountForm copy={copy} />
236
+ </ul>
237
+ <details open={scheduler.stale > 0 || scheduler.failing > 0}>
238
+ <summary className="cursor-pointer text-sm font-medium">
239
+ {translator.t('adminSystem.showTasks')}
240
+ </summary>
241
+ {scheduler.tasks.length === 0 ? (
242
+ <p className="text-sm text-muted-foreground">{translator.t('adminSystem.noTasks')}</p>
243
+ ) : (
244
+ <ul className="grid gap-x-6 sm:grid-cols-2">
245
+ {scheduler.tasks.map((task) => (
246
+ <li
247
+ key={task.key}
248
+ className="flex min-w-0 items-start justify-between gap-3 border-t border-border py-3"
249
+ >
250
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
251
+ <span className="text-sm font-medium [overflow-wrap:anywhere]">
252
+ {task.titleKey === undefined ? task.key : translator.t(task.titleKey)}
253
+ </span>
254
+ <span className="text-xs text-muted-foreground">
255
+ {translator.t('adminSystem.taskInterval', { seconds: task.intervalSeconds })}{' '}
256
+ ·{' '}
257
+ {task.lastRunAt === null
258
+ ? translator.t('adminSystem.neverRun')
259
+ : translator.t('adminSystem.taskLastRun', {
260
+ time: formatTime(task.lastRunAt, now, translator).label,
261
+ })}
262
+ {task.consecutiveFailures > 0 &&
263
+ ` · ${translator.t('adminSystem.taskFailures', {
264
+ count: task.consecutiveFailures,
265
+ })}`}
266
+ </span>
267
+ <details className="text-xs text-muted-foreground">
268
+ <summary className="cursor-pointer">
269
+ {translator.t('adminSystem.taskDetails')}
270
+ </summary>
271
+ <div className="mt-2 flex flex-col gap-1 [overflow-wrap:anywhere]">
272
+ <code>{task.key}</code>
273
+ {task.descriptionKey !== undefined && (
274
+ <p>{translator.t(task.descriptionKey)}</p>
275
+ )}
276
+ </div>
277
+ </details>
278
+ </div>
279
+ <span
280
+ className={
281
+ task.status === 'healthy' ||
282
+ task.status === 'running' ||
283
+ task.status === 'disabled'
284
+ ? 'shrink-0 text-xs text-muted-foreground'
285
+ : 'shrink-0 text-xs font-medium text-destructive'
286
+ }
287
+ >
288
+ {translator.t(TASK_STATUS_KEYS[task.status])}
289
+ </span>
290
+ </li>
291
+ ))}
292
+ </ul>
293
+ )}
294
+ </details>
315
295
  </section>
316
296
 
297
+ <section className={PANEL_CARD}>
298
+ <h2 className="font-heading text-lg font-semibold">{await tr('page.recent-runs')}</h2>
299
+ <details open={view.runs.some((run) => !run.succeeded)}>
300
+ <summary className="cursor-pointer text-sm font-medium">
301
+ {translator.t('adminSystem.showRuns')}
302
+ <span className="ml-2 font-normal text-muted-foreground">
303
+ {translator.t('adminSystem.runCount', { count: view.runs.length })}
304
+ </span>
305
+ </summary>
306
+ {view.runs.length === 0 ? (
307
+ <p className="text-sm text-muted-foreground">{await tr('page.nothing-has-run-yet')}</p>
308
+ ) : (
309
+ <ul className="mt-3 flex flex-col divide-y divide-border text-sm">
310
+ {view.runs.map((run, index) => (
311
+ <li
312
+ // biome-ignore lint/suspicious/noArrayIndexKey: the tiebreaker between two runs of one task recorded at the same instant
313
+ key={`${run.taskKey}:${run.ranAt.toISOString()}:${index}`}
314
+ className="min-w-0 py-3 [overflow-wrap:anywhere]"
315
+ >
316
+ <span className="font-medium">
317
+ {taskTitles.get(run.taskKey) === undefined
318
+ ? run.taskKey
319
+ : translator.t(taskTitles.get(run.taskKey)!)}
320
+ </span>{' '}
321
+ <span className="text-muted-foreground">
322
+ {run.succeeded
323
+ ? translator.t('adminSystem.taskRunOk')
324
+ : translator.t('adminSystem.taskRunFailed')}
325
+ {run.durationMs !== null && ` · ${run.durationMs}ms`} ·{' '}
326
+ <time dateTime={run.ranAt.toISOString()}>
327
+ {formatTime(run.ranAt, now, translator).label}
328
+ </time>
329
+ </span>
330
+ <SystemRunDetails detail={run.detail} translator={translator} />
331
+ {run.error !== null && (
332
+ <span className="block text-xs text-destructive">{run.error}</span>
333
+ )}
334
+ </li>
335
+ ))}
336
+ </ul>
337
+ )}
338
+ </details>
339
+ </section>
340
+
341
+ {legacyPasswordHashes > 0 && (
342
+ <section className={PANEL_CARD}>
343
+ <h2 className="font-heading text-lg font-semibold">
344
+ {translator.t('adminSystem.legacyPasswords')}
345
+ </h2>
346
+ <p className="text-sm">
347
+ {translator.t('adminSystem.legacyPasswordsCount', { count: legacyPasswordHashes })}
348
+ </p>
349
+ <p className="text-sm text-muted-foreground">
350
+ {translator.t('adminSystem.legacyPasswordsHint')}
351
+ </p>
352
+ </section>
353
+ )}
354
+
317
355
  <section className={PANEL_CARD}>
318
356
  <h2 className="font-heading text-lg font-semibold">{await tr('page.search-index')}</h2>
319
357
  <p className="text-sm text-muted-foreground">
@@ -332,18 +370,57 @@ export default async function AdminSystemPage() {
332
370
  <ReindexSearchForm pending={view.searchIndex.pending} copy={copy} />
333
371
  </section>
334
372
 
335
- <section className={cn(PANEL_CARD, 'gap-4')}>
373
+ <section id="maintenance" className={PANEL_CARD}>
336
374
  <h2 className="font-heading text-lg font-semibold">
337
375
  {translator.t('adminSystem.maintenance')}
338
376
  </h2>
339
- <p className="text-sm text-muted-foreground">
340
- {translator.t('adminSystem.maintenanceHint')}
341
- </p>
377
+ <a
378
+ href={
379
+ maintenanceOpen
380
+ ? '/admin/system#maintenance'
381
+ : '/admin/system?maintenance=1#maintenance'
382
+ }
383
+ className="w-fit text-sm font-medium underline underline-offset-4"
384
+ >
385
+ {translator.t(
386
+ maintenanceOpen ? 'adminSystem.closeMaintenance' : 'adminSystem.openMaintenance',
387
+ )}
388
+ </a>
389
+ {maintenanceOpen && (
390
+ <div className="flex flex-col gap-6">
391
+ <section className="flex flex-col gap-3">
392
+ <h3 className="font-heading font-semibold">{translator.t('adminSystem.recount')}</h3>
393
+ <p className="text-sm text-muted-foreground">
394
+ {translator.t('adminSystem.recountHint')}
395
+ </p>
396
+ {view.recount.length > 0 && (
397
+ <ul className="flex flex-col gap-1 text-xs text-muted-foreground">
398
+ {view.recount.map((row) => (
399
+ <li key={row.id}>
400
+ {translator.t('adminSystem.recountProgress', {
401
+ id: row.id,
402
+ phase: row.phase,
403
+ cursor: row.cursor,
404
+ passes: row.passes,
405
+ corrected: row.corrected,
406
+ })}
407
+ </li>
408
+ ))}
409
+ </ul>
410
+ )}
411
+ <RecountForm copy={copy} />
412
+ </section>
413
+
414
+ <p className="text-sm text-muted-foreground">
415
+ {translator.t('adminSystem.maintenanceHint')}
416
+ </p>
342
417
 
343
- <PruneSessionsForm prunable={view.prunableSessions} copy={copy} />
344
- <PruneTokensForm copy={copy} />
345
- <ClearCacheForm copy={copy} />
346
- <RetryJobForm copy={copy} />
418
+ <PruneSessionsForm prunable={view.prunableSessions} copy={copy} />
419
+ <PruneTokensForm copy={copy} />
420
+ <ClearCacheForm copy={copy} />
421
+ <RetryJobForm copy={copy} />
422
+ </div>
423
+ )}
347
424
  </section>
348
425
  </PanelPage>
349
426
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,55 +43,55 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.36.1",
47
- "@meith/admin": "0.36.1",
48
- "@meith/antispam": "0.36.1",
49
- "@meith/api": "0.36.1",
50
- "@meith/attachments": "0.36.1",
51
- "@meith/authorization": "0.36.1",
52
- "@meith/avatars": "0.36.1",
53
- "@meith/backup": "0.36.1",
54
- "@meith/board-digest": "0.36.1",
55
- "@meith/core": "0.36.1",
56
- "@meith/db": "0.36.1",
57
- "@meith/demo": "0.36.1",
58
- "@meith/drafts": "0.36.1",
59
- "@meith/drivers": "0.36.1",
60
- "@meith/events": "0.36.1",
61
- "@meith/forums": "0.36.1",
62
- "@meith/groups": "0.36.1",
63
- "@meith/i18n": "0.36.1",
64
- "@meith/import": "0.36.1",
65
- "@meith/install": "0.36.1",
66
- "@meith/mail": "0.36.1",
67
- "@meith/markdown": "0.36.1",
68
- "@meith/marketplace": "0.36.1",
69
- "@meith/messages": "0.36.1",
70
- "@meith/moderation": "0.36.1",
71
- "@meith/notifications": "0.36.1",
72
- "@meith/plugin-calendar": "0.36.1",
73
- "@meith/plugin-dues": "0.36.1",
74
- "@meith/plugin-kit": "0.36.1",
75
- "@meith/polls": "0.36.1",
76
- "@meith/posts": "0.36.1",
77
- "@meith/profile-fields": "0.36.1",
78
- "@meith/relations": "0.36.1",
79
- "@meith/reputation": "0.36.1",
80
- "@meith/runtime": "0.36.1",
81
- "@meith/search": "0.36.1",
82
- "@meith/settings": "0.36.1",
83
- "@meith/signatures": "0.36.1",
84
- "@meith/subscriptions": "0.36.1",
85
- "@meith/tasks": "0.36.1",
86
- "@meith/theme-clubhouse": "0.36.1",
87
- "@meith/theme-default": "0.36.1",
88
- "@meith/theme-kit": "0.36.1",
89
- "@meith/theme-midnight": "0.36.1",
90
- "@meith/theme-phasebook": "0.36.1",
91
- "@meith/theme-raidframe": "0.36.1",
92
- "@meith/threads": "0.36.1",
93
- "@meith/ui": "0.36.1",
94
- "@meith/upgrade": "0.36.1"
46
+ "@meith/accounts": "0.36.2",
47
+ "@meith/admin": "0.36.2",
48
+ "@meith/antispam": "0.36.2",
49
+ "@meith/api": "0.36.2",
50
+ "@meith/attachments": "0.36.2",
51
+ "@meith/authorization": "0.36.2",
52
+ "@meith/avatars": "0.36.2",
53
+ "@meith/backup": "0.36.2",
54
+ "@meith/board-digest": "0.36.2",
55
+ "@meith/core": "0.36.2",
56
+ "@meith/db": "0.36.2",
57
+ "@meith/demo": "0.36.2",
58
+ "@meith/drafts": "0.36.2",
59
+ "@meith/drivers": "0.36.2",
60
+ "@meith/events": "0.36.2",
61
+ "@meith/forums": "0.36.2",
62
+ "@meith/groups": "0.36.2",
63
+ "@meith/i18n": "0.36.2",
64
+ "@meith/import": "0.36.2",
65
+ "@meith/install": "0.36.2",
66
+ "@meith/mail": "0.36.2",
67
+ "@meith/markdown": "0.36.2",
68
+ "@meith/marketplace": "0.36.2",
69
+ "@meith/messages": "0.36.2",
70
+ "@meith/moderation": "0.36.2",
71
+ "@meith/notifications": "0.36.2",
72
+ "@meith/plugin-calendar": "0.36.2",
73
+ "@meith/plugin-dues": "0.36.2",
74
+ "@meith/plugin-kit": "0.36.2",
75
+ "@meith/polls": "0.36.2",
76
+ "@meith/posts": "0.36.2",
77
+ "@meith/profile-fields": "0.36.2",
78
+ "@meith/relations": "0.36.2",
79
+ "@meith/reputation": "0.36.2",
80
+ "@meith/runtime": "0.36.2",
81
+ "@meith/search": "0.36.2",
82
+ "@meith/settings": "0.36.2",
83
+ "@meith/signatures": "0.36.2",
84
+ "@meith/subscriptions": "0.36.2",
85
+ "@meith/tasks": "0.36.2",
86
+ "@meith/theme-clubhouse": "0.36.2",
87
+ "@meith/theme-default": "0.36.2",
88
+ "@meith/theme-kit": "0.36.2",
89
+ "@meith/theme-midnight": "0.36.2",
90
+ "@meith/theme-phasebook": "0.36.2",
91
+ "@meith/theme-raidframe": "0.36.2",
92
+ "@meith/threads": "0.36.2",
93
+ "@meith/ui": "0.36.2",
94
+ "@meith/upgrade": "0.36.2"
95
95
  },
96
96
  "scripts": {
97
97
  "dev": "next dev",
@@ -0,0 +1,72 @@
1
+ import type { Translator } from '@meith/i18n'
2
+
3
+ import { systemRunDetail } from '@/view/system-run-detail'
4
+
5
+ const FIELD_KEYS: Readonly<Record<string, string>> = {
6
+ attempted: 'adminSystem.result.attempted',
7
+ delivered: 'adminSystem.result.delivered',
8
+ retried: 'adminSystem.result.retried',
9
+ dead: 'adminSystem.result.dead',
10
+ relayed: 'adminSystem.result.relayed',
11
+ processed: 'adminSystem.result.processed',
12
+ removed: 'adminSystem.result.removed',
13
+ corrected: 'adminSystem.result.corrected',
14
+ flushed: 'adminSystem.result.flushed',
15
+ rendered: 'adminSystem.result.rendered',
16
+ indexed: 'adminSystem.result.indexed',
17
+ ok: 'adminSystem.result.ok',
18
+ listingCount: 'adminSystem.result.listingCount',
19
+ notified: 'adminSystem.result.notified',
20
+ promoted: 'adminSystem.result.promoted',
21
+ lifted: 'adminSystem.result.lifted',
22
+ expired: 'adminSystem.result.expired',
23
+ deleted: 'adminSystem.result.deleted',
24
+ failed: 'adminSystem.result.failed',
25
+ memberCount: 'adminSystem.result.memberCount',
26
+ online: 'adminSystem.result.online',
27
+ record: 'adminSystem.result.record',
28
+ ran: 'adminSystem.result.ran',
29
+ trigger: 'adminSystem.result.trigger',
30
+ bundle: 'adminSystem.result.bundle',
31
+ status: 'adminSystem.result.status',
32
+ skipped: 'adminSystem.result.skipped',
33
+ }
34
+
35
+ export function SystemRunDetails({
36
+ detail,
37
+ translator,
38
+ }: {
39
+ detail: string | null
40
+ translator: Translator
41
+ }) {
42
+ const fields = systemRunDetail(detail)
43
+ if (fields.length === 0) return null
44
+
45
+ const label = (key: string) => {
46
+ const message = Object.hasOwn(FIELD_KEYS, key) ? FIELD_KEYS[key] : undefined
47
+ if (message !== undefined) return translator.t(message)
48
+ const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
49
+ return words.charAt(0).toUpperCase() + words.slice(1)
50
+ }
51
+
52
+ return (
53
+ <dl className="mt-2 grid gap-x-6 gap-y-1 text-xs sm:grid-cols-2">
54
+ {fields.map((field) => (
55
+ <div key={JSON.stringify(field.path)} className="flex min-w-0 flex-wrap gap-x-2">
56
+ <dt className="text-muted-foreground">
57
+ {field.path.length === 0
58
+ ? translator.t('adminSystem.result.value')
59
+ : field.path.map(label).join(' / ')}
60
+ </dt>
61
+ <dd className="min-w-0 break-words font-medium [overflow-wrap:anywhere]">
62
+ {field.value === null
63
+ ? translator.t('adminSystem.result.empty')
64
+ : typeof field.value === 'boolean'
65
+ ? translator.t(field.value ? 'adminSystem.result.yes' : 'adminSystem.result.no')
66
+ : String(field.value)}
67
+ </dd>
68
+ </div>
69
+ ))}
70
+ </dl>
71
+ )
72
+ }
@@ -17,7 +17,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
17
17
 
18
18
  import { activeDefinitions } from './plugin-host'
19
19
 
20
- export const CODE_VERSION = '0.36.1'
20
+ export const CODE_VERSION = '0.36.2'
21
21
 
22
22
  export interface UpgradeApplied {
23
23
  readonly plugins: readonly string[]
@@ -0,0 +1,33 @@
1
+ export interface RunDetailField {
2
+ readonly path: readonly string[]
3
+ readonly value: string | number | boolean | null
4
+ }
5
+
6
+ export function systemRunDetail(detail: string | null): readonly RunDetailField[] {
7
+ if (detail === null || detail.trim() === '') return []
8
+
9
+ let parsed: unknown
10
+ try {
11
+ parsed = JSON.parse(detail)
12
+ } catch {
13
+ return [{ path: [], value: detail }]
14
+ }
15
+
16
+ const fields: RunDetailField[] = []
17
+ const pending = [{ path: [] as string[], value: parsed }]
18
+ while (pending.length > 0) {
19
+ const { path, value } = pending.pop()!
20
+ if (value !== null && typeof value === 'object') {
21
+ const entries = Array.isArray(value)
22
+ ? value.map((item, index) => [String(index + 1), item] as const)
23
+ : Object.entries(value)
24
+ if (entries.length === 0) fields.push({ path, value: null })
25
+ for (const [key, item] of entries.reverse()) {
26
+ pending.push({ path: [...path, key], value: item })
27
+ }
28
+ } else {
29
+ fields.push({ path, value: value as RunDetailField['value'] })
30
+ }
31
+ }
32
+ return fields
33
+ }