diff --git a/.changeset/system-overview-by-action-title-parity.md b/.changeset/system-overview-by-action-title-parity.md new file mode 100644 index 0000000000..c8900f1945 --- /dev/null +++ b/.changeset/system-overview-by-action-title-parity.md @@ -0,0 +1,43 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): the System Overview by-action table serves its declared title again, and the default locale bundle is now pinned to the source string (#8721) + +`widget_recent_events` was converted into an ADR-0021 single-form — a +dataset-bound breakdown of `sys_audit_log` events by action — but all four +hand-authored locale bundles kept serving the title the widget had *before* the +conversion (`Recent Audit Events` / `最近审计事件` / `最近の監査イベント` / +`Eventos de Auditoría Recientes`). The translation is what renders, so the +declared string reached nobody in any locale. Its `description` had drifted the +same way and in the same direction, one field over. + +**The duplicate the stale translation was hiding.** With the source string +restored, the board carried the same label twice: `widget_events_by_type` (a +pie) and `widget_recent_events` (a table) both declared `Audit Events by +Action`, over the same dataset and the same dimension. They looked distinct in a +running instance only because one of them was serving a stale translation. The +pair now splits on what each adds — the pie keeps `Audit Events by Action` (the +share picture), the table becomes **`Event Volume by Action`** (the exact +per-action count, which is what its `values: ['event_count']` produces and what +its description already said). All four locales are translated to the new +strings; the widget **ids are unchanged**, so no translation key, persisted +widget state or dataset binding moves. + +**Why nothing caught it, and what now does.** This package's `apps` / +`dashboards` / `pages` i18n is hand-authored and cannot be regenerated — +regenerating would delete ~40 runtime-contributed nav translations per locale — +so it never had the source-tracking the generated half gets from the extractor. +Every gate over it made a **key-set** claim (`app-nav-translation-parity.test.ts` +asserts a translation exists and does not outlive its declaration; +`check:i18n-coverage` ratchets *untranslated* labels; `check:app-nav-i18n` judges +the merged nav tree), and a key whose value is stale satisfies all of them. + +`app-nav-translation-parity.test.ts` now also asserts the **default locale's +content**: every statically declared app label, description and nav label, plus +the dashboard's label, description and every widget title/description, must +appear in `en.ts` **verbatim**. That claim is available for `en` alone because +`en` is a copy of the source rather than a translation of it — the same +invariant the generated half already enforces by rewriting its `en` bundle on +every extract. What a *translated* locale should do when its source string +changes is a separate product decision and is deliberately not decided here. diff --git a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts index 006616e43e..3444605e8a 100644 --- a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts +++ b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts @@ -15,7 +15,7 @@ import { Dashboard } from '@objectstack/spec/ui'; * 1. Platform KPIs — users / orgs / sessions / packages * 2. Security KPIs — login / config audit counts * 3. Distribution charts — audit events by action + by user - * 4. Recent audit events table + * 4. Event volume by action (table) * * This is a MIXED board, and the split decides who the date bar applies to * (#7531, #7613). Row 1 is INVENTORY — "how much of this exists right now" — @@ -201,13 +201,27 @@ export const SystemOverviewDashboard = Dashboard.create({ layout: { x: 6, y: 4, w: 6, h: 4 }, }, - // ── Row 4: Audit events by action ─────────────────────────────── + // ── Row 4: Event volume by action ─────────────────────────────── // ADR-0021 single-form: a dataset-bound breakdown of events by action. // (The raw recent-events record list belongs in a ListView on // sys_audit_log — a row-level lens, not a dashboard analytics widget.) + // + // The title says "volume", not "events by action", because the Row 3 pie + // (`widget_events_by_type`) breaks down the SAME dataset by the SAME + // dimension and had the identical title until now — two tiles on one board + // labelled `Audit Events by Action`. They read as distinct in a running + // instance only because this one was serving a stale translation from + // before the ADR-0021 conversion, so the duplicate was invisible in the UI + // and visible only in the source. The pair now splits on what each adds: + // the pie is the share picture, this table is the exact per-action count + // (`values: ['event_count']`). + // + // The id stays `widget_recent_events` deliberately — it predates the + // conversion, and renaming it would break every locale bundle's key and + // any persisted per-widget state for a cosmetic gain. { id: 'widget_recent_events', - title: 'Audit Events by Action', + title: 'Event Volume by Action', // The example actions named here have to be actions the platform can // actually emit — this string used to lead with `permission`, which // advertised the retired value from a second place on the same board. diff --git a/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts index 72ad265492..a218480c05 100644 --- a/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts +++ b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts @@ -20,9 +20,12 @@ // complete and the ratchet at 0, that tool now reports the truth; this test is // the local, CLI-independent version of the same invariant. // -// Setup is deliberately NOT covered here: its nav ids do not exist on the app -// object at all until the runtime merges contributions in, so this file would -// have nothing to walk. +// Setup's nav LEAVES are deliberately NOT covered by the presence direction +// below: they do not exist on the app object at all until the runtime merges +// contributions in, so this file would have nothing to walk. (Its nine static +// group anchors do exist statically, and the default-locale content check at +// the bottom of this file walks exactly those — see that block's own note for +// why a content claim can be made where a coverage claim cannot.) // // Where they ARE covered: `pnpm check:app-nav-i18n` // (`packages/cli/scripts/check-app-nav-i18n.mjs`), which boots the real @@ -42,6 +45,7 @@ import { describe, it, expect } from 'vitest'; import { STUDIO_APP } from '../studio.app.js'; import { ACCOUNT_APP } from '../account.app.js'; +import { SETUP_APP } from '../setup.app.js'; import { SystemOverviewDashboard } from '../dashboards/index.js'; import { en } from './en.js'; import { zhCN } from './zh-CN.js'; @@ -50,13 +54,13 @@ import { esES } from './es-ES.js'; const LOCALES = { en, 'zh-CN': zhCN, 'ja-JP': jaJP, 'es-ES': esES } as const; -/** Every nav id in an app's statically declared navigation tree, depth-first. */ -function navIds(app: { navigation?: unknown[] }): string[] { - const out: string[] = []; +/** Every statically declared nav item of an app, depth-first, with its label. */ +function navItems(app: { navigation?: unknown[] }): Array<{ id: string; label?: string }> { + const out: Array<{ id: string; label?: string }> = []; const walk = (items: unknown[]) => { for (const raw of items ?? []) { - const item = raw as { id?: string; children?: unknown[] }; - if (item?.id) out.push(item.id); + const item = raw as { id?: string; label?: string; children?: unknown[] }; + if (item?.id) out.push({ id: item.id, label: item.label }); if (Array.isArray(item?.children)) walk(item.children); } }; @@ -64,6 +68,11 @@ function navIds(app: { navigation?: unknown[] }): string[] { return out; } +/** Every nav id in an app's statically declared navigation tree, depth-first. */ +function navIds(app: { navigation?: unknown[] }): string[] { + return navItems(app).map((item) => item.id); +} + describe('statically declared app navigation is translated in every locale', () => { for (const app of [STUDIO_APP, ACCOUNT_APP] as Array<{ name: string; navigation?: unknown[] }>) { for (const [locale, data] of Object.entries(LOCALES)) { @@ -135,3 +144,131 @@ describe('dashboard widgets are translated in every locale', () => { }); } }); + +// ── The default locale serves the SOURCE string, not an old copy of it ─────── +// +// Every claim above is a key-set claim: it judges whether a key exists on one +// side or both. A key whose VALUE has gone stale satisfies all of them, and one +// did — `widget_recent_events` kept `Recent Audit Events` in all four bundles +// after the widget was converted into an ADR-0021 by-action breakdown whose +// declared title says so. Since the translation is what renders, the declared +// string reached nobody in any locale, under a fully green build. +// +// What can be asserted mechanically is the DEFAULT locale, because `en.ts` is a +// copy of the source rather than a translation of it. That is the same +// invariant the generated half of this package's i18n already enforces by +// rewriting the `en` bundle from the source on every extract (see +// `scripts/i18n-extract.config.ts`); this half is hand-authored and cannot be +// regenerated — regenerating it would delete ~40 runtime-contributed nav +// translations per locale — so the invariant is asserted here instead of being +// produced by a generator. +// +// Deliberately NOT claimed here: anything about zh-CN / ja-JP / es-ES. What a +// translated locale should do when its source string changes (keep serving the +// stale value, fall back to the source, fail the build) is a product decision, +// not a test's to invent. This block is the half that needs no decision; the +// half that does is #8765, and note what pinning `en` does to it — the drift +// stops being uniform across all four bundles and becomes locale-specific, +// invisible to every reviewer who reads the product in English. +// +// Direction: source ⇒ en, one-way. A key in `en.ts` with no declaring source is +// NOT judged — that set is exactly Setup's runtime-contributed nav leaves, +// which no static walk can see and which `pnpm check:app-nav-i18n` and +// `setup-nav-dead-key-tombstone.test.ts` own. Setup's nine static group anchors +// ARE walked: a coverage claim over Setup is impossible here (most of its ids +// are absent at import time), but a content claim over the few it does declare +// is sound — the walk judges what it finds, and finds nothing it cannot judge. +// +// `pages.*` is out of the walk on purpose: those entries mirror page metadata +// authored in OTHER packages (@objectstack/cloud-connection, @objectstack/mcp), +// which this package does not import and must not depend on to run its tests. +// That leaves the third of this bundle with no source comparison in ANY locale, +// `en` included — the same shape as the defect above, one section over, and a +// static walk in this package cannot close it. Tracked as #8764; the gate that +// can see those pages is `check:app-nav-i18n`, which already boots the real +// composition. All three were in parity when this block was written. +describe('the default locale bundle serves the declared source string verbatim', () => { + type Drift = { path: string; source: string; en: string | undefined }; + + const collect = ( + drift: Drift[], + path: string, + source: string | undefined, + served: string | undefined, + ) => { + // An undeclared source string makes no claim — only a declared one does. + if (typeof source !== 'string') return; + if (served !== source) drift.push({ path, source, en: served }); + }; + + const APPS = [SETUP_APP, STUDIO_APP, ACCOUNT_APP] as unknown as Array<{ + name: string; + label?: string; + description?: string; + navigation?: unknown[]; + }>; + + for (const app of APPS) { + it(`apps.${app.name} — label, description and every statically declared nav label`, () => { + const served = (en.apps?.[app.name] ?? {}) as { + label?: string; + description?: string; + navigation?: Record; + }; + const drift: Drift[] = []; + collect(drift, `apps.${app.name}.label`, app.label, served.label); + collect(drift, `apps.${app.name}.description`, app.description, served.description); + for (const item of navItems(app)) { + collect( + drift, + `apps.${app.name}.navigation.${item.id}.label`, + item.label, + served.navigation?.[item.id]?.label, + ); + } + expect( + drift, + `en.ts no longer matches the declared source in apps.${app.name} — ` + + 'edit the bundle to the source string (this half is hand-authored; do NOT regenerate it)', + ).toEqual([]); + }); + } + + it('dashboards.system_overview — label, description and every widget title/description', () => { + const dashboard = SystemOverviewDashboard as unknown as { + name: string; + label?: string; + description?: string; + widgets?: Array<{ id?: string; title?: string; description?: string }>; + }; + const served = (en.dashboards?.[dashboard.name] ?? {}) as { + label?: string; + description?: string; + widgets?: Record; + }; + const drift: Drift[] = []; + collect(drift, `dashboards.${dashboard.name}.label`, dashboard.label, served.label); + collect( + drift, + `dashboards.${dashboard.name}.description`, + dashboard.description, + served.description, + ); + for (const widget of dashboard.widgets ?? []) { + if (!widget.id) continue; + const base = `dashboards.${dashboard.name}.widgets.${widget.id}`; + collect(drift, `${base}.title`, widget.title, served.widgets?.[widget.id]?.title); + collect( + drift, + `${base}.description`, + widget.description, + served.widgets?.[widget.id]?.description, + ); + } + expect( + drift, + 'en.ts no longer matches the declared source in dashboards.system_overview — ' + + 'edit the bundle to the source string (this half is hand-authored; do NOT regenerate it)', + ).toEqual([]); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 76096632a1..bbbf8256cf 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -224,8 +224,8 @@ export const en: TranslationData = { description: 'Activity distribution across users', }, widget_recent_events: { - title: 'Recent Audit Events', - description: 'Latest platform events (login, logout, config, …)', + title: 'Event Volume by Action', + description: 'Event volume grouped by action (login, logout, config, …)', }, }, }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index cdfd3c5cb8..3eb8e4c783 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -151,7 +151,7 @@ export const esES: TranslationData = { widget_config_changes: { title: 'Cambios de Configuración', description: 'Modificaciones de configuración del sistema' }, widget_events_by_type: { title: 'Eventos de Auditoría por Acción', description: 'Distribución de eventos de auditoría por tipo de acción' }, widget_events_by_user: { title: 'Eventos por Usuario', description: 'Distribución de actividad entre usuarios' }, - widget_recent_events: { title: 'Eventos de Auditoría Recientes', description: 'Últimos eventos de la plataforma (inicio de sesión, cierre de sesión, configuración, …)' }, + widget_recent_events: { title: 'Volumen de Eventos por Acción', description: 'Volumen de eventos agrupado por acción (inicio de sesión, cierre de sesión, configuración, …)' }, }, }, }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 29bc84ac29..3b808d504a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -151,7 +151,7 @@ export const jaJP: TranslationData = { widget_config_changes: { title: '構成変更', description: 'システム構成の変更' }, widget_events_by_type: { title: 'アクション別監査イベント', description: 'アクションタイプ別の監査イベント分布' }, widget_events_by_user: { title: 'ユーザー別イベント', description: 'ユーザー別アクティビティ分布' }, - widget_recent_events: { title: '最近の監査イベント', description: '最新のプラットフォームイベント(ログイン、ログアウト、構成など)' }, + widget_recent_events: { title: 'アクション別イベント件数', description: 'アクション別にグループ化されたイベント件数(ログイン、ログアウト、構成など)' }, }, }, }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 70b0398459..4fc8718cee 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -161,7 +161,7 @@ export const zhCN: TranslationData = { widget_config_changes: { title: '配置变更', description: '系统配置修改' }, widget_events_by_type: { title: '按操作分布的审计事件', description: '审计事件按操作类型分布' }, widget_events_by_user: { title: '按用户分布的事件', description: '用户活动分布' }, - widget_recent_events: { title: '最近审计事件', description: '最新的平台事件(登录、登出、配置等)' }, + widget_recent_events: { title: '按操作统计的事件量', description: '按操作分组的事件量(登录、登出、配置等)' }, }, }, },