Skip to content

Commit 0e43df2

Browse files
fix(console): the audit log names each row's object by its served label (#29033)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb9uVmskJeM8TUh3pJBNhBrf)_ Fixes #28968 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Setup › 审计日志的「对象」列、详情抽屉标题、抽屉里的「目标记录」行,改为显示对象的标签(中文界面下如「客户」),不再显示 API 名 `crm_account`。元数据没有提供的对象(或标签为空)仍显示存储的 `object_name`。 - **为什么改**:#28895 之后同一行的「操作」列已显示「更新」,对象列却还是 `crm_account`,管理员得认识 API 名才知道改的是哪个业务对象;同页的「对象」筛选器早已用标签。 - **风险与回滚**:低。只改三处显示文字,标签与筛选器读同一份 `useMetadata().objects`;筛选器的「标签 (名称)」选项、列表查询都不变;无新元数据键、无新门禁。回滚即还原本 PR。 - **先看**:`ui/apps/console/src/pages/system/AuditLogPage.tsx`(新的 `objectLabelOf` 与三处调用)、`AuditLogPage.objectLabels-28968.test.tsx`、`AuditLogPage.objectFilter-13165.test.tsx`(行读取改读标签)。 - **两分钟验证**:在 `ui/` 下运行 `pnpm exec vitest run apps/console/src/pages/system/AuditLogPage apps/console/src/pages/system/auditLogActions`,预期 9 个文件、54 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **One reading of a served object, two readers.** `AuditLogPage.tsx` gains `servedObjectOf(raw)`, the name and trimmed label the Object filter already read inline, and `objectFilterOptionsOf` now calls it (same choices, same `Label (name)` spelling, same order). Beside it, `objectLabelOf(objects, name)` answers the served label of the object whose API name is `name`, else the stored name; a missing or empty name answers `undefined`. Both read the page's existing `useMetadata().objects`: no second map and no object named in code. - **The three sites, label alone (the seat's ruling).** The Object column cell, the drawer title (still falling back to "Audit Event" when a row has no object) and the drawer's target-record line (`label / record_id`) print `objectLabelOf(objects, ...)`. The Object filter is unchanged. ## Tests - **New: `AuditLogPage.objectLabels-28968.test.tsx`, 5 cases.** Served objects `crm_account` (客户) and `crm_blank` (blank label); a ledger naming `crm_account`, `nightly_widget` (served by no object), `crm_blank` and no object: - the Object cells read 客户, `nightly_widget`, `crm_blank`, and a dash; - the drawer of the served row reads 客户 in its title and `客户 / acc_1` on its target line; - the drawer of the unserved row reads `nightly_widget` and `nightly_widget / wid_1`; - with no objects served, the cells show the stored names; - the Object filter still offers `客户 (crm_account)` and `crm_blank`. - **Changed: `AuditLogPage.objectFilter-13165.test.tsx`.** Its row reader picked Object cells whose text was the API name; it now reads the labels (Accounts / Contacts) the cells show. Every assertion is otherwise the same. ## Acceptance notes - The target-record line keeps its monospace style; with a label it reads e.g. `客户 / acc_1`. The API name is still in the Object filter's choices. - No new metadata key, no new gate. The size check: 249 changed lines before the merge of `main`, well under 3000. ## Verification Fix `eda802a4`, changeset `40f0901d`; final head `f137f81a`, a merge of `main` (10 commits, none touching the audit log page or its tests). After the merge: `pnpm install`, the console's dependency closure rebuilt, and the audit log suites, the console type-check and the changeset and manifest gates re-ran. <details><summary>Audit log suites · 9 of 9 files</summary> `pnpm exec vitest run --maxWorkers=2 apps/console/src/pages/system/AuditLogPage apps/console/src/pages/system/auditLogActions` from `ui/`, under `os-verify-lock`: Test Files 9 passed (9), Tests 54 passed (54), at `40f0901d` and again at `f137f81a`. `pnpm --filter @object-ui/console run type-check`: exit 0 at both heads. </details> <details><summary>Ablations · 3 legs, each red as predicted</summary> Each leg went through `scripts/ablation-replace.mjs` against the committed fix (anchor 1 to 0, blob changed), running the new suite; each restore read blob == HEAD (`15c4495f`) and `git diff HEAD` empty. 1. The Object cell back to `r.object_name`: the cell case red (1 of 5). 2. The drawer title back to `selected?.object_name`: the served-drawer case red (1 of 5). 3. The target line back to `selected.object_name`: the served-drawer case red (1 of 5). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 33 commands at `40f0901d`; all 33 ran with exit 0. The slowest: `pnpm check:type-check-debt` under the lock (346 s, 0 raw tsc errors), `pnpm check:query-options-erasure` (246 s), `pnpm --dir ui exec vitest run scripts/` (241 s; 169 passed, 2 skipped files, 4900 tests passed), `node scripts/check-comment-mask-corpus.mjs` (160 s). `node scripts/check-sdui-manifest.mjs`: exit 0. `check-changeset-no-major --base origin/main` ran with this body as the event. At `f137f81a`: `check-sdui-manifest`, `check-empty-changeset --base origin/main` and `check-changeset-no-major` re-ran, each exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUWJpkV9c17Msdk5NL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c470581 commit 0e43df2

4 files changed

Lines changed: 232 additions & 17 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): the audit log's Object column and detail drawer show the object's label
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Setup › Audit Log printed each row's stored `sys_audit_log.object_name` (`crm_account`, `sys_flow_dispatch`) in the Object column, the detail drawer's title and its target-record line, while the same page's Object filter already labelled the served objects. In a Chinese console a row therefore read 更新 beside `crm_account`, and telling which business object changed meant knowing its API name.
11+
12+
All three now show the object's served label, read from the same served objects as the Object filter (the new `objectLabelOf` beside `objectFilterOptionsOf` in `AuditLogPage.tsx`, sharing one reading of each object). They show the label alone; the filter keeps its `Label (name)` choices. An object the metadata does not serve, or serves without a label, shows its stored name, as before. The filter and the list fetch are unchanged.

‎ui/apps/console/src/pages/system/AuditLogPage.objectFilter-13165.test.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,12 @@ async function chooseObject(option: string): Promise<void> {
207207
await settle();
208208
}
209209

210-
/** The object names of the listed rows, one per row. */
210+
/** The Object cells of the listed rows, one per row, each reading its object's served label. */
211211
function listedObjects(): string[] {
212212
return screen
213213
.queryAllByRole('cell')
214214
.map((cell) => cell.textContent ?? '')
215-
.filter((text) => text === 'crm_account' || text === 'crm_contact');
215+
.filter((text) => text === 'Accounts' || text === 'Contacts');
216216
}
217217

218218
function renderPage(): void {
@@ -247,14 +247,14 @@ describe('AuditLogPage — the object filter is picked, never typed (objectui#13
247247

248248
it('choosing Accounts sends object_name equal to crm_account and lists only its rows', async () => {
249249
renderPage();
250-
await waitFor(() => expect(listedObjects()).toContain('crm_contact'));
250+
await waitFor(() => expect(listedObjects()).toContain('Contacts'));
251251

252252
await chooseObject('Accounts (crm_account)');
253253

254254
await waitFor(() => expect(lastFilter()).toEqual({ object_name: 'crm_account' }));
255-
await waitFor(() => expect(listedObjects()).not.toContain('crm_contact'));
255+
await waitFor(() => expect(listedObjects()).not.toContain('Contacts'));
256256
expect(listedObjects()).toHaveLength(50);
257-
expect(new Set(listedObjects())).toEqual(new Set(['crm_account']));
257+
expect(new Set(listedObjects())).toEqual(new Set(['Accounts']));
258258
// The trigger names what is chosen, and the bar counts it.
259259
expect(objectTrigger()).toHaveTextContent('Accounts (crm_account)');
260260
expect(screen.getByRole('button', { name: /Clear filters \(1\)/ })).toBeInTheDocument();
@@ -263,27 +263,27 @@ describe('AuditLogPage — the object filter is picked, never typed (objectui#13
263263
it('choosing the chosen object again removes the filter and lists every object again', async () => {
264264
renderPage();
265265
await chooseObject('Accounts (crm_account)');
266-
await waitFor(() => expect(listedObjects()).not.toContain('crm_contact'));
266+
await waitFor(() => expect(listedObjects()).not.toContain('Contacts'));
267267

268268
await chooseObject('Accounts (crm_account)');
269269

270270
await waitFor(() => expect(lastFilter()).toBeNull());
271-
await waitFor(() => expect(listedObjects()).toContain('crm_contact'));
271+
await waitFor(() => expect(listedObjects()).toContain('Contacts'));
272272
expect(objectTrigger()).toHaveTextContent('Any object');
273273
});
274274

275275
it('"Clear filters" removes the object filter and lists every object again', async () => {
276276
renderPage();
277277
await chooseObject('Contacts (crm_contact)');
278-
await waitFor(() => expect(listedObjects()).not.toContain('crm_account'));
278+
await waitFor(() => expect(listedObjects()).not.toContain('Accounts'));
279279

280280
await act(async () => {
281281
fireEvent.click(screen.getByRole('button', { name: /Clear filters/ }));
282282
});
283283
await settle();
284284

285285
await waitFor(() => expect(lastFilter()).toBeNull());
286-
await waitFor(() => expect(listedObjects()).toContain('crm_account'));
286+
await waitFor(() => expect(listedObjects()).toContain('Accounts'));
287287
expect(objectTrigger()).toHaveTextContent('Any object');
288288
});
289289

@@ -342,6 +342,6 @@ describe('AuditLogPage — the object filter is picked, never typed (objectui#13
342342

343343
await waitFor(() => expect(lastFilter()).toEqual({ object_name: 'crm_contact' }));
344344
expect(requests[requests.length - 1].get('$skip')).toBe('0');
345-
await waitFor(() => expect(listedObjects()).toEqual(['crm_contact', 'crm_contact']));
345+
await waitFor(() => expect(listedObjects()).toEqual(['Contacts', 'Contacts']));
346346
});
347347
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The audit log's Object column, the detail drawer's title and its
11+
* target-record line name a row's object by its served label, the label the
12+
* same page's Object filter already offers.
13+
*
14+
* All three used to print the stored API name (`crm_account`,
15+
* `sys_flow_dispatch`), so in a Chinese console a row read 更新 beside
16+
* `crm_account`, and telling which business object changed meant knowing its
17+
* API name.
18+
*
19+
* The transport is stubbed at the seams the page reads through:
20+
* - the console's metadata cache (`useMetadata().objects`) serves the objects
21+
* as a zh console receives them: `crm_account` labelled 客户, and
22+
* `crm_blank` served with a blank label. `nightly_widget` is stored on a
23+
* row but served by no object;
24+
* - `fetch` answers `/data/sys_audit_log` with the ledger below;
25+
* - the console adapter (`useAdapter`) serves `sys_user` to the actor lookup.
26+
*
27+
* Pinned:
28+
* - each Object cell shows its object's served label, and an object the
29+
* metadata does not serve, or serves without a label, shows its stored
30+
* name; a row with no object shows the dash;
31+
* - the drawer's title and its target-record line show the same label, for a
32+
* served object and for an unserved one;
33+
* - the Object filter keeps its `Label (name)` choices.
34+
*/
35+
36+
import '@testing-library/jest-dom/vitest';
37+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
38+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
39+
import { MemoryRouter } from 'react-router-dom';
40+
41+
const { ADAPTER, METADATA, SERVED_OBJECTS } = vi.hoisted(() => {
42+
const SERVED_OBJECTS = Object.freeze([
43+
{ name: 'crm_account', label: '客户' },
44+
{ name: 'crm_blank', label: ' ' },
45+
{ name: 'sys_audit_log', label: '审计日志' },
46+
]) as readonly unknown[];
47+
return {
48+
ADAPTER: {
49+
find: async () => ({ data: [], total: 0 }),
50+
findOne: async () => null,
51+
getObjectSchema: async () => undefined,
52+
},
53+
SERVED_OBJECTS,
54+
// Mutable per test: the served objects.
55+
METADATA: { objects: SERVED_OBJECTS },
56+
};
57+
});
58+
59+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
60+
...(await importOriginal<Record<string, unknown>>()),
61+
useAdapter: () => ADAPTER,
62+
useMetadata: () => ({ objects: METADATA.objects }),
63+
}));
64+
65+
// Imported after the mocks so the page picks them up.
66+
import { AuditLogPage } from './AuditLogPage';
67+
68+
/** The stored ledger, newest first: a served object, an unserved one, a blank-labelled one, and none. */
69+
const LEDGER = [
70+
{ id: 'log_1', created_at: '2026-11-02T03:00:00.000Z', action: 'update', object_name: 'crm_account', record_id: 'acc_1', user_id: null },
71+
{ id: 'log_2', created_at: '2026-11-02T02:00:00.000Z', action: 'create', object_name: 'nightly_widget', record_id: 'wid_1', user_id: null },
72+
{ id: 'log_3', created_at: '2026-11-02T01:00:00.000Z', action: 'update', object_name: 'crm_blank', record_id: 'blk_1', user_id: null },
73+
{ id: 'log_4', created_at: '2026-11-02T00:00:00.000Z', action: 'login', object_name: null, record_id: null, user_id: null },
74+
];
75+
76+
beforeEach(() => {
77+
METADATA.objects = SERVED_OBJECTS;
78+
vi.stubGlobal(
79+
'fetch',
80+
vi.fn(async () => new Response(JSON.stringify({ object: 'sys_audit_log', records: LEDGER, total: LEDGER.length }), { status: 200 })),
81+
);
82+
});
83+
84+
afterEach(() => {
85+
cleanup();
86+
vi.unstubAllGlobals();
87+
});
88+
89+
function renderPage(): void {
90+
render(<MemoryRouter><AuditLogPage /></MemoryRouter>);
91+
}
92+
93+
/** The body rows of the log table. */
94+
function bodyRows(): HTMLElement[] {
95+
return screen.getAllByRole('row').slice(1);
96+
}
97+
98+
/** The Object cell's text, by row. */
99+
function objectCells(): string[] {
100+
return bodyRows().map((row) => row.querySelectorAll('td')[2]?.textContent ?? '');
101+
}
102+
103+
/** Open the detail drawer of the row at `index`. */
104+
async function openDrawer(index: number): Promise<HTMLElement> {
105+
await act(async () => {
106+
fireEvent.click(bodyRows()[index]!);
107+
});
108+
return screen.findByRole('dialog');
109+
}
110+
111+
/** The drawer title's object, the text after its action badge. */
112+
function drawerTitleObject(dialog: HTMLElement): string {
113+
const title = within(dialog).getByRole('heading');
114+
const badge = title.firstElementChild?.textContent ?? '';
115+
return (title.textContent ?? '').slice(badge.length);
116+
}
117+
118+
/** The drawer's target-record line, `object / record`. */
119+
function drawerTarget(dialog: HTMLElement): string {
120+
const caption = within(dialog).getByText('Target record');
121+
return caption.nextElementSibling?.textContent?.trim() ?? '';
122+
}
123+
124+
describe('AuditLogPage — the Object column and drawer show the served object label', () => {
125+
it('labels each Object cell with its served label, and an unserved or unlabelled object with its stored name', async () => {
126+
renderPage();
127+
await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length));
128+
129+
expect(objectCells()).toEqual(['客户', 'nightly_widget', 'crm_blank', '—']);
130+
});
131+
132+
it("names a served object by its label in the drawer's title and target line", async () => {
133+
renderPage();
134+
await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length));
135+
136+
const dialog = await openDrawer(0);
137+
expect(drawerTitleObject(dialog)).toBe('客户');
138+
expect(drawerTarget(dialog)).toBe('客户 / acc_1');
139+
});
140+
141+
it("names an unserved object by its stored name in the drawer's title and target line", async () => {
142+
renderPage();
143+
await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length));
144+
145+
const dialog = await openDrawer(1);
146+
expect(drawerTitleObject(dialog)).toBe('nightly_widget');
147+
expect(drawerTarget(dialog)).toBe('nightly_widget / wid_1');
148+
});
149+
150+
it('shows stored names when the metadata serves no objects', async () => {
151+
METADATA.objects = [];
152+
renderPage();
153+
await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length));
154+
155+
expect(objectCells()).toEqual(['crm_account', 'nightly_widget', 'crm_blank', '—']);
156+
});
157+
158+
it('keeps the Object filter on its Label (name) choices', async () => {
159+
renderPage();
160+
await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length));
161+
162+
await act(async () => {
163+
fireEvent.click(screen.getByRole('combobox', { name: 'Object' }));
164+
});
165+
const options = (await screen.findAllByRole('option')).map((o) => o.textContent);
166+
expect(options).toContain('客户 (crm_account)');
167+
expect(options).toContain('crm_blank');
168+
});
169+
});

‎ui/apps/console/src/pages/system/AuditLogPage.tsx‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
* own view when one is chosen (objectui#12667)
2424
* - The actor is named, not shown as a raw id (objectui#11701): see
2525
* `actorOf` below
26+
* - The object is named by its served label in the Object column and the
27+
* drawer: see `objectLabelOf` below
2628
* - Paginated table (50/page)
2729
* - Row click opens a side drawer with full event details + JSON
2830
* diff of old_value → new_value when present
@@ -209,14 +211,46 @@ function auditPresetsOf(objectDef: unknown): AuditPreset[] {
209211
function objectFilterOptionsOf(objects: readonly unknown[], locale: string): ComboboxOption[] {
210212
const options: ComboboxOption[] = [];
211213
for (const raw of objects) {
212-
const object = raw as { name?: unknown; label?: unknown } | null | undefined;
213-
if (!object || typeof object.name !== 'string' || !object.name) continue;
214-
const label = typeof object.label === 'string' && object.label.trim() ? object.label.trim() : undefined;
215-
options.push({ value: object.name, label: objectRefOptionLabel(object.name, label) });
214+
const object = servedObjectOf(raw);
215+
if (!object) continue;
216+
options.push({ value: object.name, label: objectRefOptionLabel(object.name, object.label) });
216217
}
217218
return options.sort((a, b) => a.label.localeCompare(b.label, locale));
218219
}
219220

221+
/**
222+
* The object a row names, as the Object column, the drawer's title and its
223+
* target-record line show it: the served label of the object whose API name
224+
* is `name`, read from the same `useMetadata().objects` as the Object filter's
225+
* choices (`objectFilterOptionsOf`), so a cell and the filter name an object
226+
* from one source. These sites show the label alone; the filter keeps its
227+
* `Label (name)` spelling, where the API name tells similar labels apart.
228+
*
229+
* An object the metadata does not serve, or serves without a label, shows its
230+
* stored `object_name`, so a row is never left unnamed. A missing or empty
231+
* name answers `undefined` and the page shows its own fallback.
232+
*/
233+
function objectLabelOf(objects: readonly unknown[], name: string | null | undefined): string | undefined {
234+
if (!name) return undefined;
235+
for (const raw of objects) {
236+
const object = servedObjectOf(raw);
237+
if (object?.name === name) return object.label ?? name;
238+
}
239+
return name;
240+
}
241+
242+
/**
243+
* One entry of the served objects read as its API name and its trimmed label
244+
* (`undefined` when blank), or `undefined` for an entry that names no object.
245+
* The one reading behind `objectFilterOptionsOf` and `objectLabelOf`.
246+
*/
247+
function servedObjectOf(raw: unknown): { name: string; label?: string } | undefined {
248+
const object = raw as { name?: unknown; label?: unknown } | null | undefined;
249+
if (!object || typeof object.name !== 'string' || !object.name) return undefined;
250+
const label = typeof object.label === 'string' && object.label.trim() ? object.label.trim() : undefined;
251+
return { name: object.name, label };
252+
}
253+
220254
/**
221255
* `locale` is REQUIRED: the log's timestamp column used to format with no
222256
* tag, i.e. in the MACHINE's locale (objectui#9909). The page passes
@@ -660,7 +694,7 @@ export function AuditLogPage() {
660694
>
661695
<TableCell className="font-mono text-xs">{formatDate(r.created_at, displayLocale)}</TableCell>
662696
<TableCell><Badge variant={variant}>{actionLabelOf(actionOptions, r.action) ?? '—'}</Badge></TableCell>
663-
<TableCell>{r.object_name || '—'}</TableCell>
697+
<TableCell>{objectLabelOf(objects, r.object_name) ?? '—'}</TableCell>
664698
<TableCell className="font-mono text-xs">{truncate(r.record_id, 18)}</TableCell>
665699
<TableCell><ActorCell face={actorOf(r)} /></TableCell>
666700
<TableCell className="font-mono text-xs">{r.ip_address || '—'}</TableCell>
@@ -700,7 +734,7 @@ export function AuditLogPage() {
700734
<Badge variant={actionBadgeVariant(selected?.action)}>
701735
{actionLabelOf(actionOptions, selected?.action) ?? '—'}
702736
</Badge>
703-
{selected?.object_name || t('console.system.auditLog.eventFallback', { defaultValue: 'Audit Event' })}
737+
{objectLabelOf(objects, selected?.object_name) ?? t('console.system.auditLog.eventFallback', { defaultValue: 'Audit Event' })}
704738
</SheetTitle>
705739
<SheetDescription>
706740
{selected?.id ? <span className="font-mono text-xs">{selected.id}</span> : null}
@@ -729,7 +763,7 @@ export function AuditLogPage() {
729763
<div className="col-span-2">
730764
<div className="text-xs text-muted-foreground">{t('console.system.auditLog.targetRecord', { defaultValue: 'Target record' })}</div>
731765
<div className="font-mono text-sm break-all">
732-
{selected.object_name || '—'} / {selected.record_id || '—'}
766+
{objectLabelOf(objects, selected.object_name) ?? '—'} / {selected.record_id || '—'}
733767
</div>
734768
</div>
735769
{selected.user_agent && (

0 commit comments

Comments
 (0)