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
43 changes: 43 additions & 0 deletions .changeset/system-overview-by-action-title-parity.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" —
Expand DownExpand Up@@ -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.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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';
Expand All@@ -50,20 +54,25 @@ 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);
}
};
walk(app.navigation ?? []);
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)) {
Expand DownExpand Up@@ -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<string, { label?: string }>;
};
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<string, { title?: string; description?: string }>;
};
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([]);
});
});
4 changes: 2 additions & 2 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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, …)',
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/apps/translations/es-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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, …)' },
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: 'アクション別にグループ化されたイベント件数(ログイン、ログアウト、構成など)' },
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '按操作分组的事件量(登录、登出、配置等)' },
},
},
},
Expand Down
Loading