diff --git a/changelog.d/779-admin-search-ui.changed.md b/changelog.d/779-admin-search-ui.changed.md new file mode 100644 index 00000000..141325cb --- /dev/null +++ b/changelog.d/779-admin-search-ui.changed.md @@ -0,0 +1 @@ +`/admin/search` now uses one filter toolbar with `Jobs` / `Audit` tabs, one Query field, a primary Search action and a secondary export with the active count. Audit rows show human action labels once, render `key=value` details as chips, link job/repository/user ids, and collapse long errors instead of printing one truncated raw line. diff --git a/docs/architecture.md b/docs/architecture.md index 0047c91d..37fe6726 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -319,13 +319,18 @@ recorded". `scripts/restore-drill.mjs --status-file` is the writer. rebuilds that URL keeps the state it is not changing: the 7/30-day window, both "Clear filters" links and both pagers read the keys from one helper, so the filter survives a window switch and a page click, and the window survives - a filter and a clear. The admin search page (`/admin/search`) builds its two - job filters from those same `JOB_FILTER_STATUSES` and `JOB_FILTER_KINDS` lists - and the same label helpers, so every kind a job can have is selectable there - and both locales read the console's words rather than the stored code; the - option values stay the codes, so the query, the totals and the CSV export are - unchanged. A second hand-written list in a view is how `scope` and `verify` - went missing from a form once already. + a filter and a clear. The admin search page (`/admin/search`) uses one toolbar + with a `Jobs` / `Audit` switch (`view=jobs|audit`): the active tab owns the + only filter form, so there is one Query field and paging or export cannot drop + the other tab's unrelated keys. The job fields still come from + `JOB_FILTER_STATUSES` and `JOB_FILTER_KINDS` and the same label helpers, so + every kind a job can have is selectable there and both locales read the + console's words rather than the stored code; option values stay the codes, so + the query, totals and CSV export are unchanged. Audit details render + `key=value` pairs as chips, link `job_*`, `repo_*` and `usr_*` ids to their + pages, and collapse long free text behind a details control instead of + truncating one raw line. A second hand-written list in a view is how `scope` + and `verify` went missing from a form once already. - **Admin CSV exports**: both `/admin/export/jobs.csv` and `/admin/export/audit.csv` write one `admin.jobs_export` / `admin.audit_export` row once the body is known — `filters=` echoes the allow-listed codes and ISO diff --git a/public/app.css b/public/app.css index 0e7385e2..1e13a7c9 100644 --- a/public/app.css +++ b/public/app.css @@ -733,6 +733,34 @@ textarea { resize: vertical; min-height: 70px; } .job-filters select { width: 100%; min-height: var(--touch); } .job-filter-actions { display: flex; align-items: center; gap: 10px; } +/* Admin search: one toolbar, tabs, and a readable audit detail cell. */ +.search-head { align-items: flex-start; gap: 18px; flex-wrap: wrap; } +.search-tabs { display: flex; align-items: center; gap: 6px; padding: 3px; border: 1px solid var(--line); border-radius: var(--radius-pill); background: var(--field); } +.search-tab { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: var(--radius-pill); color: var(--muted); font-size: 13px; text-decoration: none; } +.search-tab.is-active { background: var(--panel); color: var(--text); box-shadow: 0 1px 0 var(--line); } +.search-tab .count { font-variant-numeric: tabular-nums; color: var(--muted-2); font-size: 12px; } +.search-filters { display: grid; gap: 12px; margin: 14px 0 10px; } +.filter-grid { display: grid; grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(140px, 1fr)); gap: 12px; align-items: end; } +.filter-grid label { display: grid; gap: 6px; min-width: 0; font-size: 13px; color: var(--muted); } +.filter-grid input, .filter-grid select { width: 100%; } +.filter-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.filter-actions .btn { flex: 0 0 auto; } +.filter-export { margin-left: auto; } +.audit-detail { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; min-width: 0; } +.audit-detail > span { min-width: 0; overflow-wrap: anywhere; } +.kv { display: inline-flex; align-items: baseline; gap: 4px; padding: 1px 7px; border: 1px solid var(--line); border-radius: var(--radius-pill); background: var(--surface-raised); font-size: 12px; } +.kv b { font-weight: 600; color: var(--muted-2); } +.audit-more { flex-basis: 100%; } +.audit-more summary { cursor: pointer; color: var(--muted-2); font-size: 12px; } +.audit-more pre { margin: 6px 0 0; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; } +@media (max-width: 900px) { .filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 680px) { + .search-head { flex-direction: column; } + .search-tabs { width: 100%; justify-content: space-between; } + .filter-grid { grid-template-columns: minmax(0, 1fr); } + .filter-export { margin-left: 0; } +} + .pager { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 14px; } .pager-info { color: var(--muted-2); font-size: 12px; font-variant-numeric: tabular-nums; } diff --git a/src/app.ts b/src/app.ts index 97cb1603..b957635e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4585,6 +4585,7 @@ export async function buildApp(options: BuildOptions): Promise { const action = (queryText(request, 'action') ?? '').slice(0, 80) const from = (queryText(request, 'from') ?? '').slice(0, 20) const to = (queryText(request, 'to') ?? '').slice(0, 20) + const view = queryText(request, 'view') === 'audit' ? 'audit' : 'jobs' const perPage = 20 const page = (name: string, total: number): number => { const n = Number(queryText(request, name)) @@ -4605,7 +4606,7 @@ export async function buildApp(options: BuildOptions): Promise { jobs: store.searchJobs(userIds, jobFilters, perPage, (jobsPage - 1) * perPage), audit: store.searchAudit(userIds, auditFilters, perPage, (auditPage - 1) * perPage), usersById, - filters: { q, status, kind, priority, action, from, to }, + filters: { q, status, kind, priority, action, from, to, view }, jobsPage, jobsTotal, auditPage, auditTotal, diff --git a/src/views/pages.ts b/src/views/pages.ts index 71a289fa..dec57cf8 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -2320,6 +2320,11 @@ const AUDIT_ACTIONS: Record = { 'flybot.instance.ready': ['FlyBot 实例就绪', 'FlyBot instance ready'], 'flybot.instance.failed': ['FlyBot 实例失败', 'FlyBot instance failed'], 'flybot.instance.stopped': ['FlyBot 实例停止', 'FlyBot instance stopped'], + 'flybot.instance.auto_restarted': ['自动重启实例', 'Instance auto-restarted'], + 'flybot.instance.recovered': ['实例已恢复', 'Instance recovered'], + 'session.revoked': ['撤销会话', 'Session revoked'], + 'admin.support_view': ['查看支持详情', 'Support view'], + 'job.cancelled': ['任务取消', 'Job cancelled'], 'token.created': ['创建 token', 'Token created'], 'token.revoked': ['撤销 token', 'Token revoked'], 'model.saved': ['保存模型', 'Model saved'], @@ -2357,6 +2362,30 @@ function auditTone(action: string): string { return '' } +function auditLinkify(text: string): string { + const parts = text.split(/(\b(?:job|repo|usr)_[A-Za-z0-9_-]+\b)/g) + return parts.map((part) => { + if (!/^(?:job|repo|usr)_/.test(part)) return escapeHtml(part) + const href = part.startsWith('job_') ? `/admin/jobs/${part}` : part.startsWith('repo_') ? `/repos/${part}` : `/admin/users/${part}` + return `${escapeHtml(part)}` + }).join('') +} + +function auditDetailHtml(t: Translate, detail: string): string { + if (detail === '') return '—' + const chips: string[] = [] + const rest = detail.replace(/\b([A-Za-z][\w.-]*)=("[^"]*"|[^\s]+)/g, (_match, key: string, value: string) => { + chips.push(`${escapeHtml(key)}${escapeHtml(value.replace(/^"|"$/g, ''))}`) + return ' ' + }).replace(/\s+/g, ' ').trim() + const restHtml = rest === '' + ? '' + : rest.length > 160 + ? `${auditLinkify(rest.slice(0, 120))}…
${escapeHtml(t('展开详情', 'Show details'))}
${escapeHtml(rest)}
` + : `${auditLinkify(rest)}` + return `
${chips.join('')}${restHtml}
` +} + function auditRows(locale: Locale, audit: AuditRow[], usersById: Map, hideActor = false): string { const t = translator(locale) if (audit.length === 0) return stateCell(4, { kind: 'empty', title: t('暂无记录。', 'No records yet.'), body: t('审批、任务和设置变更都会写进审计;现在还没有任何一条。', 'Approvals, jobs and setting changes are audited; there is nothing yet.'), action: { href: '/dashboard', label: t('回到仓库', 'Back to repositories') } }) @@ -2371,17 +2400,17 @@ function auditRows(locale: Locale, audit: AuditRow[], usersById: Map→ @${escapeHtml(target)}`}` + const detailHtml = auditDetailHtml(t, detail) return ` ${escapeHtml(humanTimeShort(row.at))} - ${escapeHtml(auditLabel(t, row.action))}${rawAction === '' ? '' : `
${escapeHtml(rawAction)}`} + ${escapeHtml(auditLabel(t, row.action))} ${actorHtml} - ${detail === '' ? '—' : `${escapeHtml(detail.slice(0, 160))}`} + ${detailHtml} ` }) .join('') @@ -4209,7 +4238,7 @@ export function adminSearchPage(input: { jobs: JobRow[] audit: AuditRow[] usersById: Map - filters: { q: string; status: string; kind: string; priority: string; action: string; from: string; to: string } + filters: { q: string; status: string; kind: string; priority: string; action: string; from: string; to: string; view: 'jobs' | 'audit' } jobsPage: number jobsTotal: number auditPage: number @@ -4236,10 +4265,56 @@ export function adminSearchPage(input: { const auditTable = auditRows(locale, audit, usersById, false) const jobQuery = query({ q: filters.q, status: filters.status, kind: filters.kind, priority: filters.priority }) const auditQuery = query({ q: filters.q, action: filters.action, from: filters.from, to: filters.to }) + const view = filters.view + const isAudit = view === 'audit' + const hasFilter = [filters.q, filters.status, filters.kind, filters.priority, filters.action, filters.from, filters.to].some((value) => value !== '') + const tabHref = (target: 'jobs' | 'audit'): string => `/admin/search?${query({ view: target, q: filters.q })}` + const tab = (target: 'jobs' | 'audit', label: string, total: number): string => + `${escapeHtml(label)} ${total}` + const queryField = `` + const statusField = `` + const kindField = `` + const priorityField = `` + const actionField = `` + const fromField = `` + const toField = `` + const activeTotal = isAudit ? auditTotal : jobsTotal + const activePage = isAudit ? auditPage : jobsPage + const activeQuery = isAudit ? auditQuery : jobQuery + const filterForm = `` + const table = isAudit + ? `
${auditTable}
${escapeHtml(t('时间', 'Time'))}${escapeHtml(t('动作', 'Action'))}${escapeHtml(t('操作者', 'Actor'))}${escapeHtml(t('详情', 'Detail'))}
` + : `
${jobRows}
${escapeHtml(t('任务', 'Job'))}${escapeHtml(t('状态', 'Status'))}${escapeHtml(t('用户', 'User'))}PR / Issue
` + const activePager = isAudit + ? pager('/admin/search', 'auditPage', auditPage, auditTotal, perPage, t, { view: 'audit', q: filters.q, action: filters.action, from: filters.from, to: filters.to }) + : pager('/admin/search', 'jobsPage', jobsPage, jobsTotal, perPage, t, { view: 'jobs', q: filters.q, status: filters.status, kind: filters.kind, priority: filters.priority }) const body = `← ${escapeHtml(t('返回管理', 'Back to admin'))} -
${escapeHtml(t('搜索与导出', 'SEARCH & EXPORT'))}

${escapeHtml(t('审计与任务', 'Audit and jobs'))}

${escapeHtml(t('按用户范围过滤;可导出当前结果。', 'Scoped by the same user visibility as the admin page; export the current result.'))}

-

${escapeHtml(t('任务', 'Jobs'))}

${escapeHtml(t('导出 CSV', 'Export CSV'))}
${lastExportLine(lastJobsExport)}
${jobRows}
${escapeHtml(t('任务', 'Job'))}${escapeHtml(t('状态', 'Status'))}${escapeHtml(t('用户', 'User'))}PR / Issue

${escapeHtml(t('共', 'Total'))} ${jobsTotal} · ${escapeHtml(t('第', 'Page'))} ${jobsPage}

${pager('/admin/search', 'jobsPage', jobsPage, jobsTotal, perPage, t, { q: filters.q, status: filters.status, kind: filters.kind, priority: filters.priority, auditPage })}
-

${escapeHtml(t('审计', 'Audit'))}

${escapeHtml(t('导出 CSV', 'Export CSV'))}
${lastExportLine(lastAuditExport)}${auditTable}

${escapeHtml(t('共', 'Total'))} ${auditTotal} · ${escapeHtml(t('第', 'Page'))} ${auditPage}

${pager('/admin/search', 'auditPage', auditPage, auditTotal, perPage, t, { q: filters.q, action: filters.action, from: filters.from, to: filters.to, jobsPage })}
` +
+
+
+
${escapeHtml(t('搜索与导出', 'SEARCH & EXPORT'))}
+

${escapeHtml(t('审计与任务', 'Audit and jobs'))}

+

${escapeHtml(t('按用户范围过滤;切换任务或审计,只显示当前筛选。', 'Scoped by the same user visibility as the admin page; switch between jobs and audit, and only the active filter is applied.'))}

+
+ +
+ ${filterForm} + ${lastExportLine(isAudit ? lastAuditExport : lastJobsExport)} +
+
+

${escapeHtml(isAudit ? t('审计', 'Audit') : t('最近任务', 'Recent jobs'))} ${activeTotal}

${escapeHtml(t('每页 20 条', '20 per page'))}
+ ${table} +

${escapeHtml(t('共', 'Total'))} ${activeTotal} · ${escapeHtml(t('第', 'Page'))} ${activePage}

+ ${activePager} +
` return layout({ title: t('审计与任务', 'Audit and jobs'), viewer, body, config, locale, path: '/admin/search', active: 'admin', notice, error }) } diff --git a/test/admin.test.ts b/test/admin.test.ts index c4be27c7..d1cb9170 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -589,3 +589,40 @@ test('the admin header summarizes sections and folds user controls behind manage await built.close() } }) + +test('admin search uses one toolbar and renders audit details as structure, not a log line', async () => { + const config = testConfig() + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const admin = makeUser(store, { login: 'search-ui-admin', admin: true }) + const user = makeUser(store, { login: 'search-ui-user', status: 'approved' }) + store.audit(user.id, 'repo.refreshed', 'permission=admin role=maintainer auto=role-recheck') + store.audit(user.id, 'flybot.instance.auto_restarted', `repo_abc: Error: ${'bridge '.repeat(40)}`) + const { cookie } = sessionFor(built, admin) + + const jobs = await built.app.inject({ method: 'GET', url: '/admin/search', headers: { cookie } }) + assert.equal(jobs.statusCode, 200) + assert.match(jobs.body, /]*class="search-filters"[^>]*role="search"/) + assert.equal((jobs.body.match(/name="q"/g) ?? []).length, 1, 'one query field, not one per card') + assert.match(jobs.body, /type="search"[^>]*name="q"/) + assert.match(jobs.body, /class="search-tab[^"]*"[^>]*aria-current="page"/) + assert.match(jobs.body, /P0 —/, 'the priority option carries its console label') + + const audit = await built.app.inject({ method: 'GET', url: '/admin/search?view=audit', headers: { cookie } }) + assert.equal(audit.statusCode, 200) + assert.match(audit.body, /type="date"[^>]*name="from"/) + assert.match(audit.body, /type="date"[^>]*name="to"/) + assert.match(audit.body, /permission<\/b>admin<\/span>/) + assert.match(audit.body, /role<\/b>maintainer<\/span>/) + assert.doesNotMatch(audit.body, /repo\.refreshed<\/small>/, 'the action label is not repeated as a raw line') + assert.match(audit.body, /自动重启实例/, 'the flybot event has a human label') + assert.match(audit.body, /
/, 'a long error is collapsed') + assert.match(audit.body, /\/admin\/export\/audit\.csv\?/) + + const filtered = await built.app.inject({ method: 'GET', url: '/admin/search?view=audit&action=repo.refreshed&from=2026-11-01&to=2026-11-15', headers: { cookie } }) + assert.match(filtered.body, /\/admin\/export\/audit\.csv\?[^"]*action=repo\.refreshed/) + } finally { + await built.close() + } +}) diff --git a/test/pages.test.ts b/test/pages.test.ts index 0ce8a58c..01ebe585 100644 --- a/test/pages.test.ts +++ b/test/pages.test.ts @@ -1912,39 +1912,42 @@ test('a job stopped by its budget names the action and the next step, in both la } }) -test('admin search links every later page and keeps the filters per section', () => { +test('admin search links every later page and keeps the active tab filters', () => { const config = testConfig() const store = new Store(':memory:') try { const user = makeUser(store, { login: 'search-pager', status: 'approved' }) const viewer = { user: store.userById(user.id)!, csrfToken: 'csrf' } - const filters = { q: 'x', status: 'failed', kind: 'iterate', priority: '', action: 'repo.settings', from: '2026-01-01', to: '2026-01-31' } - const page = adminSearchPage({ config, locale: 'en', viewer, jobs: [], audit: [], usersById: new Map([[user.id, user.login]]), filters, jobsPage: 1, jobsTotal: 45, auditPage: 2, auditTotal: 60, perPage: 20, lastJobsExport: null, lastAuditExport: null }) + const filters = { q: 'x', status: 'failed', kind: 'iterate', priority: '', action: 'repo.settings', from: '2026-01-01', to: '2026-01-31', view: 'jobs' as const } + const jobsPage = adminSearchPage({ config, locale: 'en', viewer, jobs: [], audit: [], usersById: new Map([[user.id, user.login]]), filters, jobsPage: 1, jobsTotal: 45, auditPage: 2, auditTotal: 60, perPage: 20, lastJobsExport: null, lastAuditExport: null }) - const jobsNext = page.match(/href="([^"]*jobsPage=2[^"]*)"/)?.at(1) - assert.ok(jobsNext, 'the jobs section links to its own second page') + const jobsNext = jobsPage.match(/href="([^"]*jobsPage=2[^"]*)"/)?.at(1) + assert.ok(jobsNext, 'the jobs tab links to its own second page') assert.match(jobsNext, /q=x/, 'the jobs link keeps the keyword') assert.match(jobsNext, /status=failed/, 'the jobs link keeps the status filter') assert.match(jobsNext, /kind=iterate/, 'the jobs link keeps the kind filter') - assert.match(jobsNext, /auditPage=2/, 'the jobs link keeps the audit section on its page') + assert.match(jobsNext, /view=jobs/, 'the jobs link keeps the active tab') assert.doesNotMatch(jobsNext, /action=/, 'the jobs link carries no audit-only filter') + assert.doesNotMatch(jobsNext, /auditPage=/, 'the jobs link carries no audit page') - const auditNext = page.match(/href="([^"]*auditPage=3[^"]*)"/)?.at(1) - assert.ok(auditNext, 'the audit section links to its own next page') + const auditPage = adminSearchPage({ config, locale: 'en', viewer, jobs: [], audit: [], usersById: new Map([[user.id, user.login]]), filters: { ...filters, view: 'audit' as const }, jobsPage: 1, jobsTotal: 45, auditPage: 2, auditTotal: 60, perPage: 20, lastJobsExport: null, lastAuditExport: null }) + const auditNext = auditPage.match(/href="([^"]*auditPage=3[^"]*)"/)?.at(1) + assert.ok(auditNext, 'the audit tab links to its own next page') assert.match(auditNext, /q=x/, 'the audit link keeps the keyword') assert.match(auditNext, /action=repo\.settings/, 'the audit link keeps the action filter') assert.match(auditNext, /from=2026-01-01/, 'the audit link keeps the date range') - assert.match(auditNext, /jobsPage=1/, 'the audit link keeps the jobs section on its page') + assert.match(auditNext, /view=audit/, 'the audit link keeps the active tab') assert.doesNotMatch(auditNext, /status=/, 'the audit link carries no jobs-only filter') + assert.doesNotMatch(auditNext, /jobsPage=/, 'the audit link carries no jobs page') - const short = adminSearchPage({ config, locale: 'en', viewer, jobs: [], audit: [], usersById: new Map(), filters, jobsPage: 1, jobsTotal: 20, auditPage: 1, auditTotal: 20, perPage: 20, lastJobsExport: null, lastAuditExport: null }) - assert.doesNotMatch(short, /jobsPage=2/, 'a section that fits one page renders no jobs pager') - assert.doesNotMatch(short, /auditPage=2/, 'a section that fits one page renders no audit pager') + const shortJobs = adminSearchPage({ config, locale: 'en', viewer, jobs: [], audit: [], usersById: new Map(), filters, jobsPage: 1, jobsTotal: 20, auditPage: 1, auditTotal: 20, perPage: 20, lastJobsExport: null, lastAuditExport: null }) + assert.doesNotMatch(shortJobs, /jobsPage=2/, 'a tab that fits one page renders no jobs pager') + const shortAudit = adminSearchPage({ config, locale: 'en', viewer, jobs: [], audit: [], usersById: new Map(), filters: { ...filters, view: 'audit' as const }, jobsPage: 1, jobsTotal: 20, auditPage: 1, auditTotal: 20, perPage: 20, lastJobsExport: null, lastAuditExport: null }) + assert.doesNotMatch(shortAudit, /auditPage=2/, 'a tab that fits one page renders no audit pager') } finally { store.close() } }) - // #453 review: the settings page linked its zero-token empty state at `#tokens` // while the card carrying that anchor did not exist, so the visual matrix's // `getElementById('tokens')` returned null and its "section clears the sticky