diff --git a/.changeset/system-overview-tile-label-query-agreement.md b/.changeset/system-overview-tile-label-query-agreement.md new file mode 100644 index 0000000000..0e4e1b80ce --- /dev/null +++ b/.changeset/system-overview-tile-label-query-agreement.md @@ -0,0 +1,38 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): make the System Overview "Total Users" and "Active Sessions" tiles count what their labels say (#7531) + +Two tiles on the shipped **System Overview** board reported a different quantity +from the one on the card. Neither number was stale or fabricated — each equalled +its own captured query and an independent direct aggregate — the query was +simply answering a different question from the label. + +**"Total Users" was a 7-day count.** The board declares a `created_at` global +filter defaulting to `last_7_days`, and a dashboard-level filter is broadcast +into *every* widget's analytics query (#2501). `sys_user.created_at` exists, so +the broadcast landed on it and the tile reported "users created in the last 7 +days" under a label that says "Total". On a fresh datastore the two coincide — +every user *is* recent — which is why it reads as correct in a demo and as a +catastrophic user-loss event on any instance older than the window. The tile now +opts out with `filterBindings: { created_at: false }`. + +**"Active Sessions" counted every session.** `sys_session_metrics` is a bare +count over `sys_session` and the widget carried no predicate, so a signed-out or +long-expired session was still reported as active. `sys_session` can express +"active" exactly (ADR-0069 D4): the tile now filters +`{ revoked_at: null, expires_at: { $gt: '{now}' } }`. It opts out of the date +bar as well — "currently active" is a statement about now, not about a window, +so an old session that is still live must still count. + +The date bar is untouched where it belongs: all six `sys_audit_log` widgets +(rows 2-4) still inherit it, which is what it was added for. + +No labels changed and no translation keys move — the fix is to the queries, not +the wording. Behaviour change to be aware of when upgrading: on an instance +older than the selected window both tiles will now read **higher** than before +for Total Users, and typically **lower** for Active Sessions. + +Still outstanding, filed separately: the same `created_at` fan-out also reaches +the other two Row 1 inventory tiles, "Organizations" and "Packages Installed". diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 6eb5242a74..c41ce8b7bc 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -72,6 +72,8 @@ "@objectstack/spec": "workspace:*" }, "devDependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/formula": "workspace:*", "@types/node": "^26.1.2", "tsup": "^8.5.1", "typescript": "^6.0.3", diff --git a/packages/platform-objects/src/apps/dashboards/system-overview-tile-semantics.test.ts b/packages/platform-objects/src/apps/dashboards/system-overview-tile-semantics.test.ts new file mode 100644 index 0000000000..3247cb86f7 --- /dev/null +++ b/packages/platform-objects/src/apps/dashboards/system-overview-tile-semantics.test.ts @@ -0,0 +1,273 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// System Overview tile label/query agreement (#7531). +// +// Two tiles on the shipped board reported something other than what their +// labels promised. "Total Users" was a 7-day count, because the dashboard's +// `created_at` global filter is broadcast into EVERY widget's analytics query +// (#2501) and `sys_user.created_at` happens to exist; "Active Sessions" was a +// count of every `sys_session` row, because the dataset is a bare count and the +// widget carried no predicate at all. Both numbers were LIVE and correct for +// the query issued — the query was answering a different question from the one +// on the card. +// +// # What this file pins, and what it deliberately does not +// +// ⛔ NOT a snapshot. A test that freezes today's numbers goes green again on the +// day the label drifts back, which is the one failure this card exists to stop. +// What is pinned is the PROPERTY: the question a tile's label asks and the +// question its effective query answers are the same question. +// +// - a TOTAL is invariant under the date bar — the same rows whatever window +// is selected, and none at all when it is cleared; +// - an ACTIVE count equals an active-only count computed independently from +// the fixture, and equals it at NOW rather than within a window. +// +// Each assertion carries its OPPOSITE DIRECTION in the same block, because +// every one of them has a way to pass vacuously: +// +// - "the total does not move" also holds if the harness never applies a +// window at all ⇒ a control widget on the same board (an audit tile, which +// the date bar legitimately scopes) must MOVE across the same two windows; +// - "the total does not move" also holds if the fixture has no rows outside +// the window ⇒ the same tile computed WITHOUT its opt-out must report a +// strictly smaller number on this fixture, so deleting `filterBindings` +// turns the first assertion red; +// - "the sessions count is active-only" also holds if the predicate matched +// nothing, or everything ⇒ the kept set is named row by row, and both the +// expired and the revoked row must be dropped while an OLD-but-live session +// is kept. +// +// # Why the query is composed here rather than imported +// +// The broadcast itself lives in objectui (`DashboardRenderer` merges each +// dashboard-level filter into a widget's `DatasetSelection`), so no code in +// THIS repo performs it. `composeWidgetQuery` below applies the precedence the +// contract states on `DashboardWidgetSchema.filterBindings` — string re-targets, +// `false` opts out, absent inherits the filter's own `field` — the same rule +// `@objectstack/lint`'s `effectiveFilterField` mirrors for the static gate. +// +// Everything downstream of that composition is the REAL machinery, not a +// re-implementation: `resolveFilterTokens` is the shipped resolver the analytics +// dataset executor applies to a widget's `runtimeFilter`, and +// `matchesFilterCondition` is one of the backends the repo holds to the shared +// `FILTER_LOGIC_CASES` table. So a `{now}` that stopped resolving, or a `$gt` +// that stopped meaning `$gt`, fails here. + +import { describe, it, expect } from 'vitest'; +import { resolveFilterTokens } from '@objectstack/core'; +import { matchesFilterCondition } from '@objectstack/formula'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { SystemOverviewDashboard } from './system_overview.dashboard.js'; + +/** One fixed instant, so nothing in this file depends on the wall clock. */ +const NOW = new Date('2026-08-11T12:00:00.000Z'); +const DAY_MS = 86_400_000; +const daysAgo = (n: number): Date => new Date(NOW.getTime() - n * DAY_MS); +const daysAhead = (n: number): Date => new Date(NOW.getTime() + n * DAY_MS); + +interface Widget { + id?: string; + filter?: FilterCondition; + filterBindings?: Record; +} +interface GlobalFilter { + name?: string; + field?: string; +} +const board = SystemOverviewDashboard as unknown as { + widgets?: Widget[]; + globalFilters?: GlobalFilter[]; +}; + +const widgetById = (id: string): Widget => { + const w = (board.widgets ?? []).find((x) => x.id === id); + if (!w) throw new Error(`no widget "${id}" on the System Overview board`); + return w; +}; + +/** + * The effective query a widget issues: every dashboard-level filter it is bound + * to, ANDed with the widget's own presentation-scope `filter`, with `{token}` + * placeholders resolved at {@link NOW}. + * + * `windowDays: null` models the date bar cleared. `ignoreOptOut` models the + * PRE-#7531 board — the broadcast reaching a widget that today opts out — and + * exists only so each invariance claim can be shown to be load-bearing on this + * fixture rather than true by accident. + */ +function composeWidgetQuery( + widgetId: string, + windowDays: number | null, + opts: { ignoreOptOut?: boolean } = {}, +): FilterCondition | undefined { + const widget = widgetById(widgetId); + const conjuncts: FilterCondition[] = []; + + if (windowDays !== null) { + for (const gf of board.globalFilters ?? []) { + if (!gf.field) continue; + const name = gf.name ?? gf.field; + const binding = widget.filterBindings?.[name]; + if (binding === false && !opts.ignoreOptOut) continue; + const field = typeof binding === 'string' ? binding : gf.field; + conjuncts.push({ + [field]: { $gte: daysAgo(windowDays).toISOString(), $lte: NOW.toISOString() }, + } as FilterCondition); + } + } + + if (widget.filter) conjuncts.push(widget.filter); + + const merged = + conjuncts.length === 0 ? undefined + : conjuncts.length === 1 ? conjuncts[0] + : ({ $and: conjuncts } as FilterCondition); + + return merged === undefined ? undefined : resolveFilterTokens(merged, { now: NOW }); +} + +const keep = >( + rows: T[], + filter: FilterCondition | undefined, +): T[] => rows.filter((r) => matchesFilterCondition(r, filter)); + +// ── Fixtures ──────────────────────────────────────────────────────────────── +// Every set deliberately straddles the 7-day window, because a fixture whose +// rows are all recent makes every assertion in this file pass for free — which +// is the exact trap the card describes ("on a fresh datastore this is +// indistinguishable from the true total"). + +const USERS = [ + { id: 'u_founding', created_at: daysAgo(400) }, + { id: 'u_last_quarter', created_at: daysAgo(200) }, + { id: 'u_this_week', created_at: daysAgo(2) }, +]; + +const AUDIT_EVENTS = [ + { id: 'e_ancient', action: 'login', created_at: daysAgo(400) }, + { id: 'e_old', action: 'login', created_at: daysAgo(200) }, + { id: 'e_recent', action: 'login', created_at: daysAgo(2) }, +]; + +const SESSIONS = [ + // Signed in this week, still valid — active by any reading. + { id: 's_active', created_at: daysAgo(2), expires_at: daysAhead(1), revoked_at: null }, + // The row the date-bar opt-out is FOR: signed in long ago on a long-lived + // token and still live right now. An "Active Sessions" tile that windows on + // `created_at` drops it. + { id: 's_old_active', created_at: daysAgo(400), expires_at: daysAhead(30), revoked_at: null }, + // Lapsed: never revoked, but the token is past its expiry. + { id: 's_expired', created_at: daysAgo(2), expires_at: daysAgo(1), revoked_at: null }, + // Signed out (or revoked by idle/absolute/admin policy, ADR-0069 D4) while + // the token itself is still within its expiry window. + { id: 's_revoked', created_at: daysAgo(2), expires_at: daysAhead(1), revoked_at: daysAgo(0.125) }, +]; + +/** + * "Active" computed from the fixture directly, with no reference to the board. + * This is the INDEPENDENT count the tile has to match — deriving it from the + * widget's own filter would make the comparison a tautology. + */ +const ACTIVE_SESSIONS = SESSIONS.filter( + (s) => s.revoked_at == null && s.expires_at.getTime() > NOW.getTime(), +); + +// ── The date bar is still a date bar ───────────────────────────────────────── + +describe('the dashboard filter the two tiles opt out of', () => { + // `filterBindings` is keyed by the filter's NAME, which defaults to its + // `field`. Give the global filter an explicit `name` and every opt-out below + // silently stops matching — the widgets go back to being windowed, with no + // gate anywhere to say so. So the key is pinned, not assumed. + it('is still named `created_at`', () => { + const names = (board.globalFilters ?? []).map((gf) => gf.name ?? gf.field); + expect(names).toContain('created_at'); + }); + + // The opposite direction for the change as a whole: the fix must not have + // disabled the date bar wholesale. The audit rows are what it exists to + // scope, and they must all still inherit it. + it('still reaches every audit widget', () => { + const auditWidgets = [ + 'widget_login_events', + 'widget_permission_changes', + 'widget_config_changes', + 'widget_events_by_type', + 'widget_events_by_user', + 'widget_recent_events', + ]; + for (const id of auditWidgets) { + expect(widgetById(id).filterBindings?.created_at, `${id} must stay windowed`).not.toBe(false); + } + }); +}); + +// ── Tile 1: "Total Users" ─────────────────────────────────────────────────── + +describe('widget_total_users — "Total" means total', () => { + it('reports the same users under any date-range window, and with none', () => { + for (const windowDays of [7, 30, 365, null]) { + expect( + keep(USERS, composeWidgetQuery('widget_total_users', windowDays)).map((u) => u.id), + `window=${windowDays}`, + ).toEqual(['u_founding', 'u_last_quarter', 'u_this_week']); + } + }); + + it('opposite direction — the harness really does apply the window (an audit tile moves)', () => { + // Without this, "the total does not move" would also be satisfied by a + // harness that never applied a date predicate to anything. + expect(keep(AUDIT_EVENTS, composeWidgetQuery('widget_events_by_type', 7)).map((e) => e.id)) + .toEqual(['e_recent']); + expect(keep(AUDIT_EVENTS, composeWidgetQuery('widget_events_by_type', 365)).map((e) => e.id)) + .toEqual(['e_old', 'e_recent']); + }); + + it('opposite direction — the opt-out is load-bearing on this fixture', () => { + // The pre-#7531 board, on the same rows: two of the three users disappear + // under a label that says "Total". If `filterBindings` is ever dropped, the + // first assertion in this block goes red rather than quietly passing. + const windowed = keep(USERS, composeWidgetQuery('widget_total_users', 7, { ignoreOptOut: true })); + expect(windowed.map((u) => u.id)).toEqual(['u_this_week']); + expect(windowed.length).toBeLessThan(USERS.length); + }); +}); + +// ── Tile 2: "Active Sessions" ─────────────────────────────────────────────── + +describe('widget_active_sessions — "Active" means active now', () => { + it('matches an independently-computed active-only count, under any window', () => { + expect(ACTIVE_SESSIONS.map((s) => s.id)).toEqual(['s_active', 's_old_active']); + for (const windowDays of [7, 30, 365, null]) { + expect( + keep(SESSIONS, composeWidgetQuery('widget_active_sessions', windowDays)).map((s) => s.id), + `window=${windowDays}`, + ).toEqual(ACTIVE_SESSIONS.map((s) => s.id)); + } + }); + + it('opposite direction — the predicate drops the expired and the revoked row', () => { + // "Equals the active-only count" would also hold if the predicate kept + // everything and the fixture happened to be all-active. It does not: + // the fixture carries one lapsed and one signed-out session, and the tile's + // number must be strictly below the raw row count it used to report. + const kept = keep(SESSIONS, composeWidgetQuery('widget_active_sessions', null)); + expect(SESSIONS).toHaveLength(4); + expect(kept).toHaveLength(2); + expect(kept.map((s) => s.id)).not.toContain('s_expired'); + expect(kept.map((s) => s.id)).not.toContain('s_revoked'); + }); + + it('opposite direction — an old-but-live session is counted, and windowing would drop it', () => { + // The half of this tile's fix that is about the date bar rather than the + // predicate. `s_old_active` is unambiguously active; a tile that windows on + // `created_at` reports it as gone. + const windowed = keep( + SESSIONS, + composeWidgetQuery('widget_active_sessions', 7, { ignoreOptOut: true }), + ); + expect(windowed.map((s) => s.id)).toEqual(['s_active']); + expect(windowed.length).toBeLessThan(ACTIVE_SESSIONS.length); + }); +}); 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 6b12011140..4a1c48db30 100644 --- a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts +++ b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts @@ -16,6 +16,14 @@ import { Dashboard } from '@objectstack/spec/ui'; * 2. Security KPIs — login / permission / config audit counts * 3. Distribution charts — audit events by action + by user * 4. Recent audit events table + * + * This is a MIXED board, and the split decides who the date bar applies to + * (#7531). Row 1 is INVENTORY — "how much of this exists right now" — so a + * count there must not move when the date bar moves. Rows 2-4 are ACTIVITY + * over `sys_audit_log`, which the date bar exists to scope (see the Row 3 + * note). The `globalFilters` entry below is broadcast into EVERY widget's + * analytics query (#2501), so an inventory tile has to say so: it opts out + * with `filterBindings: { created_at: false }`. */ export const SystemOverviewDashboard = Dashboard.create({ name: 'system_overview', @@ -34,6 +42,16 @@ export const SystemOverviewDashboard = Dashboard.create({ title: 'Total Users', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 }, + // #7531 — a TOTAL must not move when the date bar moves. The dashboard's + // `created_at` global filter is broadcast into every widget's analytics + // query (#2501), so before this opt-out the tile reported "users created + // in the last 7 days" under a label that says "Total". On a fresh + // datastore the two coincide — every user IS recent — which is exactly + // why it reads as correct in a demo and as catastrophic user loss on any + // instance older than the window. The date bar belongs to the audit rows + // below (see the Row 3 note); reaching `sys_user.created_at` was + // bare-field fan-out, not this tile's intent. + filterBindings: { created_at: false }, colorVariant: 'teal', description: 'Total registered users in the system', }, @@ -58,6 +76,24 @@ export const SystemOverviewDashboard = Dashboard.create({ title: 'Active Sessions', type: 'metric', layout: { x: 6, y: 0, w: 3, h: 2 }, + // #7531 — "Active Sessions" counted EVERY `sys_session` row: the dataset + // is a bare count and the widget carried no predicate, so a signed-out or + // long-expired session was still reported as active. `sys_session` can + // express "active" exactly (ADR-0069 D4): a session is live while it has + // not been revoked and has not yet expired. + // + // `{now}` is a declared date macro (`date-macros.zod.ts`) resolved + // per-request by `resolveFilterTokens`, which the analytics dataset + // executor applies to a widget's `filter` (its `runtimeFilter`) — so this + // is a live predicate, not the unsubstituted `NOW() - INTERVAL` shape the + // Row 3 note warns about. + filter: { revoked_at: null, expires_at: { $gt: '{now}' } }, + // …and "currently active" is a statement about NOW, not about a window: + // an old session that is still live is still active. Same #2501 fan-out + // as Total Users above — without this opt-out the tile would report + // "sessions CREATED in the last 7 days that are active", which is neither + // the old number nor the labelled one. + filterBindings: { created_at: false }, colorVariant: 'blue', description: 'Number of currently active user sessions', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf393d32e..23a65aaa1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1305,6 +1305,12 @@ importers: specifier: workspace:* version: link:../spec devDependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../core + '@objectstack/formula': + specifier: workspace:* + version: link:../formula '@types/node': specifier: ^26.1.2 version: 26.1.2