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
38 changes: 38 additions & 0 deletions .changeset/system-overview-tile-label-query-agreement.md
Original file line number Diff line number Diff line change
@@ -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".
2 changes: 2 additions & 0 deletions packages/platform-objects/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string | false>;
}
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 = <T extends Record<string, unknown>>(
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
},
Expand All @@ -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',
},
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading