diff --git a/examples/app-showcase/src/views/project.view.ts b/examples/app-showcase/src/views/project.view.ts index e79b735e3..68d96bdb7 100644 --- a/examples/app-showcase/src/views/project.view.ts +++ b/examples/app-showcase/src/views/project.view.ts @@ -33,7 +33,11 @@ export const ProjectViews = defineView({ type: 'chart', data, columns: ['account', 'budget'], - chart: { chartType: 'bar', xAxisField: 'account', yAxisFields: ['budget', 'spent'], aggregation: 'sum' }, + chart: { + chartType: 'bar', xAxisField: 'account', yAxisFields: ['budget', 'spent'], aggregation: 'sum', + // ADR-0021 dual-form — bind to the project dataset. + dataset: 'showcase_project_metrics', dimensions: ['account'], values: ['budget_sum', 'spent_sum'], + }, }, }, formViews: { diff --git a/examples/app-showcase/src/views/task.view.ts b/examples/app-showcase/src/views/task.view.ts index 7cbfdeed7..1b5206cf5 100644 --- a/examples/app-showcase/src/views/task.view.ts +++ b/examples/app-showcase/src/views/task.view.ts @@ -150,6 +150,10 @@ export const TaskViews = defineView({ yAxisFields: ['estimate_hours'], aggregation: 'sum', groupByField: 'priority', + // ADR-0021 dual-form — bind to the task dataset. + dataset: 'showcase_task_metrics', + dimensions: ['status', 'priority'], + values: ['est_hours'], }, }, }, diff --git a/packages/platform-objects/src/apps/dashboards/index.ts b/packages/platform-objects/src/apps/dashboards/index.ts index 9e6dce958..ce6d4a93c 100644 --- a/packages/platform-objects/src/apps/dashboards/index.ts +++ b/packages/platform-objects/src/apps/dashboards/index.ts @@ -6,3 +6,4 @@ */ export { SystemOverviewDashboard } from './system_overview.dashboard.js'; +export { SystemOverviewDatasets } from './system.datasets.js'; diff --git a/packages/platform-objects/src/apps/dashboards/system.datasets.ts b/packages/platform-objects/src/apps/dashboards/system.datasets.ts new file mode 100644 index 000000000..a5b464a34 --- /dev/null +++ b/packages/platform-objects/src/apps/dashboards/system.datasets.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +/** + * Datasets backing the System Overview dashboard (ADR-0021). Each is a single- + * object count/breakdown over a platform `sys_*` object. The audit objects live + * in `@objectstack/plugin-audit` and `sys_package_installation` is cloud-only — + * datasets reference objects BY NAME, so co-locating them with the dashboard + * (registered by plugin-auth) is fine; the widgets that target absent objects + * are runtime-gated via `requiresObject`. + */ + +export const SysUserDataset = defineDataset({ + name: 'sys_user_metrics', + label: 'User Metrics', + object: 'sys_user', + dimensions: [], + measures: [{ name: 'user_count', label: 'Users', aggregate: 'count' }], +}); + +export const SysOrganizationDataset = defineDataset({ + name: 'sys_organization_metrics', + label: 'Organization Metrics', + object: 'sys_organization', + dimensions: [], + measures: [{ name: 'org_count', label: 'Organizations', aggregate: 'count' }], +}); + +export const SysSessionDataset = defineDataset({ + name: 'sys_session_metrics', + label: 'Session Metrics', + object: 'sys_session', + dimensions: [], + measures: [{ name: 'session_count', label: 'Sessions', aggregate: 'count' }], +}); + +export const SysPackageInstallationDataset = defineDataset({ + name: 'sys_package_installation_metrics', + label: 'Package Installation Metrics', + object: 'sys_package_installation', + dimensions: [], + measures: [{ name: 'package_count', label: 'Installations', aggregate: 'count' }], +}); + +export const SysAuditLogDataset = defineDataset({ + name: 'sys_audit_log_metrics', + label: 'Audit Log Metrics', + object: 'sys_audit_log', + dimensions: [ + { name: 'action', label: 'Action', field: 'action', type: 'string' }, + { name: 'user_id', label: 'User', field: 'user_id', type: 'lookup' }, + ], + measures: [{ name: 'event_count', label: 'Events', aggregate: 'count' }], +}); + +export const SystemOverviewDatasets = [ + SysUserDataset, + SysOrganizationDataset, + SysSessionDataset, + SysPackageInstallationDataset, + SysAuditLogDataset, +]; 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 51bff4c33..1b7c25e8e 100644 --- a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts +++ b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts @@ -30,6 +30,7 @@ export const SystemOverviewDashboard = Dashboard.create({ // ── Row 1: Platform KPIs ──────────────────────────────────────── { id: 'widget_total_users', + dataset: 'sys_user_metrics', values: ['user_count'], title: 'Total Users', type: 'metric', object: 'sys_user', @@ -40,6 +41,7 @@ export const SystemOverviewDashboard = Dashboard.create({ }, { id: 'widget_organizations', + dataset: 'sys_organization_metrics', values: ['org_count'], title: 'Organizations', type: 'metric', object: 'sys_organization', @@ -50,6 +52,7 @@ export const SystemOverviewDashboard = Dashboard.create({ }, { id: 'widget_active_sessions', + dataset: 'sys_session_metrics', values: ['session_count'], title: 'Active Sessions', type: 'metric', object: 'sys_session', @@ -60,6 +63,7 @@ export const SystemOverviewDashboard = Dashboard.create({ }, { id: 'widget_packages_installed', + dataset: 'sys_package_installation_metrics', values: ['package_count'], title: 'Packages Installed', type: 'metric', object: 'sys_package_installation', @@ -80,6 +84,7 @@ export const SystemOverviewDashboard = Dashboard.create({ // need a richer enum or a separate detail field first. { id: 'widget_login_events', + dataset: 'sys_audit_log_metrics', values: ['event_count'], title: 'Login Events', type: 'metric', object: 'sys_audit_log', @@ -91,6 +96,7 @@ export const SystemOverviewDashboard = Dashboard.create({ }, { id: 'widget_permission_changes', + dataset: 'sys_audit_log_metrics', values: ['event_count'], title: 'Permission Changes', type: 'metric', object: 'sys_audit_log', @@ -102,6 +108,7 @@ export const SystemOverviewDashboard = Dashboard.create({ }, { id: 'widget_config_changes', + dataset: 'sys_audit_log_metrics', values: ['event_count'], title: 'Config Changes', type: 'metric', object: 'sys_audit_log', @@ -120,6 +127,7 @@ export const SystemOverviewDashboard = Dashboard.create({ // to scope these widgets. { id: 'widget_events_by_type', + dataset: 'sys_audit_log_metrics', dimensions: ['action'], values: ['event_count'], title: 'Audit Events by Action', description: 'Distribution of audit events by action type', type: 'pie', @@ -130,6 +138,7 @@ export const SystemOverviewDashboard = Dashboard.create({ }, { id: 'widget_events_by_user', + dataset: 'sys_audit_log_metrics', dimensions: ['user_id'], values: ['event_count'], title: 'Events by User', description: 'Activity distribution across users', type: 'bar', diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 9d93d0285..f42471a03 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -9,6 +9,7 @@ import { STUDIO_APP, ACCOUNT_APP, SystemOverviewDashboard, + SystemOverviewDatasets, } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; import { AuthManager, type AuthManagerOptions } from './auth-manager.js'; @@ -203,6 +204,8 @@ export class AuthPlugin implements Plugin { // not exist on sys_user). Schema-embedded listViews is the single // source of truth. dashboards: [SystemOverviewDashboard], + // ADR-0021 — datasets backing the System Overview dashboard's widgets. + datasets: SystemOverviewDatasets, }); ctx.logger.info('Auth Plugin initialized successfully'); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 003ee3ad3..93a42afaa 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -303,10 +303,35 @@ export const KanbanConfigSchema = lazySchema(() => z.object({ */ export const ListChartConfigSchema = lazySchema(() => z.object({ chartType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').describe('Chart visualisation type'), - xAxisField: z.string().describe('Field used as the X axis / category dimension'), - yAxisFields: z.array(z.string()).min(1).describe('Field(s) used as the Y axis / measures'), - aggregation: z.enum(['sum', 'avg', 'count', 'min', 'max']).optional().describe('Aggregation function applied to Y axis fields'), - groupByField: z.string().optional().describe('Optional field used to split / stack the chart'), + // Inline shape — optional since ADR-0021: a charted list view may instead bind + // to a semantic-layer `dataset` (see `dataset`/`dimensions`/`values` below). + xAxisField: z.string().optional().describe('Field used as the X axis / category dimension (inline)'), + yAxisFields: z.array(z.string()).min(1).optional().describe('Field(s) used as the Y axis / measures (inline)'), + aggregation: z.enum(['sum', 'avg', 'count', 'min', 'max']).optional().describe('Aggregation function applied to Y axis fields (inline)'), + groupByField: z.string().optional().describe('Optional field used to split / stack the chart (inline)'), + + /** + * ADR-0021 — bind this chart to a semantic-layer `dataset` (the governed + * alternative to the inline `xAxisField` + `yAxisFields` + `aggregation` + * query), mirroring dashboard widgets. Selects dimensions/measures BY NAME so + * the chart's numbers stay consistent with every other surface using the same + * dataset. Additive / dual-form: inline charts are unchanged. + */ + dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'), + /** Dimension names (from the dataset) for X / group / split. Dataset-bound only. */ + dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split (dataset-bound)'), + /** Measure names (from the dataset) for the value axis. Dataset-bound only. */ + values: z.array(z.string()).optional().describe('Measure names — Y (dataset-bound)'), +}).superRefine((c, ctx) => { + // ADR-0021 dual-form: a chart is EITHER dataset-bound (needs `values`) OR an + // inline query (needs `xAxisField` + `yAxisFields`). + if (c.dataset) { + if (!c.values || c.values.length === 0) { + ctx.addIssue({ code: 'custom', message: 'a dataset-bound chart needs `values` (measure names).', path: ['values'] }); + } + } else if (!c.xAxisField || !c.yAxisFields || c.yAxisFields.length === 0) { + ctx.addIssue({ code: 'custom', message: 'an inline chart needs `xAxisField` + `yAxisFields` (or bind a `dataset`).', path: ['xAxisField'] }); + } }).describe('List chart view configuration')); /**