From 25dc7b901e562e6f96bb53598bf8a2bdf0df842a Mon Sep 17 00:00:00 2001
From: rcourtman
Date: Thu, 23 Apr 2026 23:31:38 +0100
Subject: [PATCH] Add dashboard Pulse Brief
---
.../v6/internal/subsystems/ai-runtime.md | 7 +
.../subsystems/frontend-primitives.md | 4 +-
.../subsystems/performance-and-scalability.md | 9 +-
.../internal/subsystems/storage-recovery.md | 7 +-
.../dashboardOverview/EstateSummaryPanel.tsx | 100 +++++++--
.../dashboardOverview/PulseBriefPanel.tsx | 85 +++++++
.../dashboardPulseBriefModel.test.ts | 151 +++++++++++++
.../dashboardPulseBriefModel.ts | 209 ++++++++++++++++++
.../dashboardOverview/dashboardSectionIds.ts | 2 +
.../src/features/dashboardOverview/index.ts | 2 +
.../useDashboardPulseBrief.ts | 41 ++++
frontend-modern/src/pages/Dashboard.tsx | 62 +++++-
.../pages/__tests__/DashboardPage.test.tsx | 148 ++++++++++++-
13 files changed, 802 insertions(+), 25 deletions(-)
create mode 100644 frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx
create mode 100644 frontend-modern/src/features/dashboardOverview/__tests__/dashboardPulseBriefModel.test.ts
create mode 100644 frontend-modern/src/features/dashboardOverview/dashboardPulseBriefModel.ts
create mode 100644 frontend-modern/src/features/dashboardOverview/dashboardSectionIds.ts
create mode 100644 frontend-modern/src/features/dashboardOverview/useDashboardPulseBrief.ts
diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md
index f5040a4a5..ce0b56d7c 100644
--- a/docs/release-control/v6/internal/subsystems/ai-runtime.md
+++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md
@@ -882,6 +882,13 @@ The `/api/ai/intelligence/changes` endpoint should also route through the
canonical unified-intelligence recent-change accessor before any
patrol-local detector fallback, so the API surface reads the same unified
timeline source that powers the summary payload.
+Dashboard Pulse Brief context follows the same monitoring-first AI boundary:
+Pulse may surface an Assistant-ready operator paragraph on the dashboard, but
+the first render must be fact-bound to governed dashboard, Patrol, storage,
+recovery, and alert summaries and must pass those facts as structured context
+when the operator asks Assistant to continue. Future server-generated dashboard
+briefs must keep that structured fact contract and policy boundary rather than
+letting an unbounded prompt become the dashboard's source of truth.
Those backend AI and Patrol change summaries should derive their canonical
labels and provenance fragments from
`internal/unifiedresources/change_presentation.go`, so the resource-model
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 35367881d..478203883 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -1617,7 +1617,9 @@ must stay additive to the factual dashboard source of truth, derive its first
render from the already-owned estate, overview, action, storage, and recovery
facts, and hand off to Pulse Assistant through a structured context prompt
instead of replacing the route's canonical numbers, tables, or lane-owned
-widgets with model prose.
+widgets with model prose. `PulseBriefPanel.tsx`, `useDashboardPulseBrief.ts`,
+and `dashboardPulseBriefModel.ts` own that presentation and deterministic
+first-render summary contract under `frontend-modern/src/features/dashboardOverview/`.
The recovery feature shell now also depends on the shared
`frontend-modern/src/components/shared/Subtabs.tsx` primitive for its primary
protected-items versus recovery-events workspace switch. The recovery lane may
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 75774b576..f239f3677 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -285,7 +285,14 @@ regression protection.
Estate-summary issue copy on that route must use the already-hydrated
compact overview problem-resource and alert counts; it must not introduce a
second resource scan, alert fetch, storage read, or recovery read just to
- clarify first-viewport copy.
+ clarify first-viewport copy. In-page issue anchors must therefore remain
+ pure section focus links against already-rendered dashboard sections rather
+ than triggering new data hydration.
+ Dashboard Pulse Brief composition belongs to that same hot-path rule: the
+ first render may issue the existing non-blocking AI runtime settings
+ readiness read and consume existing action-state signals, but it must not
+ issue an LLM request, mount a second resource scan, or block estate/KPI
+ rendering just to produce prose.
32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. When that assistant shell changes presentation, `frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical owner for launcher, drawer, session-menu, and empty-state copy so hot-path consumers do not grow one-off inline strings or extra state branches alongside the mounted shell. Blocking shared dialogs must also suppress closed assistant affordances through the shared dialog runtime instead of leaving the mounted shell clickable behind another overlay.
Approval presentation inside that mounted assistant shell must stay
state-local to the existing drawer/session state and backend approval
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index fb93798e8..b6d7874cb 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -80,7 +80,12 @@ querying, and the operator-facing storage health presentation layer.
Dashboard route composition may place storage and recovery widgets beside
the estate summary, but estate-summary copy must not present storage or
recovery health as resolved. Storage and recovery readiness claims remain
- owned by `DashboardStoragePanel` and `DashboardRecoveryStatusPanel`.
+ owned by `DashboardStoragePanel` and `DashboardRecoveryStatusPanel`; estate
+ summary anchors may focus the dashboard alerts or problem-resource sections
+ but must not redirect into storage or recovery ownership. Optional dashboard
+ Pulse Brief copy may summarize storage and recovery facts that are already on
+ the route, but it must not become the owner of storage capacity, storage
+ health, protected-item, or recovery-outcome readiness claims.
4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes
That same adjacent API boundary also owns TrueNAS feature-default semantics for
provider-backed recovery: storage and recovery must treat `truenas_disabled`
diff --git a/frontend-modern/src/features/dashboardOverview/EstateSummaryPanel.tsx b/frontend-modern/src/features/dashboardOverview/EstateSummaryPanel.tsx
index 1aa710a46..90aad391c 100644
--- a/frontend-modern/src/features/dashboardOverview/EstateSummaryPanel.tsx
+++ b/frontend-modern/src/features/dashboardOverview/EstateSummaryPanel.tsx
@@ -1,7 +1,11 @@
-import { For, Show } from 'solid-js';
+import { For, Show, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { INFRASTRUCTURE_PATH } from '@/routing/resourceLinks';
import { formatRelativeTime } from '@/utils/format';
+import {
+ DASHBOARD_ALERTS_SECTION_ID,
+ DASHBOARD_PROBLEM_RESOURCES_SECTION_ID,
+} from './dashboardSectionIds';
import type {
DashboardEstateHealthTone,
DashboardEstateSummary,
@@ -50,24 +54,94 @@ const pluralize = (count: number, singular: string, plural = `${singular}s`): st
const reviewText = (count: number): string =>
`${pluralize(count, 'system')} ${count === 1 ? 'needs' : 'need'} review`;
-function dashboardIssueText(props: EstateSummaryPanelProps): string | null {
- const resourceIssueCount = Math.max(0, Math.trunc(props.resourceIssueCount ?? 0));
- const activeAlertCount = Math.max(0, Math.trunc(props.activeAlertCount ?? 0));
+const sectionLinkClass =
+ 'font-medium text-blue-600 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 dark:text-blue-400';
+
+function normalizedIssueCounts(props: EstateSummaryPanelProps): {
+ resourceIssueCount: number;
+ activeAlertCount: number;
+} {
+ return {
+ resourceIssueCount: Math.max(0, Math.trunc(props.resourceIssueCount ?? 0)),
+ activeAlertCount: Math.max(0, Math.trunc(props.activeAlertCount ?? 0)),
+ };
+}
+
+function focusDashboardSection(event: MouseEvent & { currentTarget: HTMLAnchorElement }) {
+ const targetId = event.currentTarget.hash.slice(1);
+ if (!targetId) return;
+
+ const target = document.getElementById(targetId);
+ if (!target) return;
+
+ event.preventDefault();
+ if (window.location.hash !== `#${targetId}`) {
+ window.history.pushState(null, '', `#${targetId}`);
+ }
+ target.scrollIntoView({ block: 'start', behavior: 'smooth' });
+ target.focus({ preventScroll: true });
+}
+
+function DashboardSectionLink(props: { href: string; children: JSX.Element }) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+function DashboardIssueLinks(props: EstateSummaryPanelProps) {
+ const { resourceIssueCount, activeAlertCount } = normalizedIssueCounts(props);
if (resourceIssueCount > 0 && activeAlertCount > 0) {
- return `${pluralize(resourceIssueCount, 'resource issue')} and ${pluralize(activeAlertCount, 'alert')} below`;
+ return (
+ <>
+
+ {pluralize(resourceIssueCount, 'resource issue')}
+
+ and
+
+ {pluralize(activeAlertCount, 'alert')}
+
+ below
+ >
+ );
+ }
+ if (resourceIssueCount > 0) {
+ return (
+ <>
+
+ {pluralize(resourceIssueCount, 'resource issue')}
+
+ below
+ >
+ );
+ }
+ if (activeAlertCount > 0) {
+ return (
+ <>
+
+ {pluralize(activeAlertCount, 'alert')}
+
+ below
+ >
+ );
}
- if (resourceIssueCount > 0) return `${pluralize(resourceIssueCount, 'resource issue')} below`;
- if (activeAlertCount > 0) return `${pluralize(activeAlertCount, 'alert')} active`;
return null;
}
+function hasDashboardIssueLinks(props: EstateSummaryPanelProps): boolean {
+ const { resourceIssueCount, activeAlertCount } = normalizedIssueCounts(props);
+ return resourceIssueCount > 0 || activeAlertCount > 0;
+}
+
function dashboardIssueSubtext(props: EstateSummaryPanelProps): string | null {
const resourceIssueCount = Math.max(0, Math.trunc(props.resourceIssueCount ?? 0));
const activeAlertCount = Math.max(0, Math.trunc(props.activeAlertCount ?? 0));
- if (resourceIssueCount > 0 && activeAlertCount > 0)
+ if (resourceIssueCount > 0 && activeAlertCount > 0) {
return 'Resource issues and alerts listed below';
+ }
if (resourceIssueCount > 0) return 'Resource issues listed below';
if (activeAlertCount > 0) return 'Alerts listed below';
return null;
@@ -92,10 +166,6 @@ export function EstateSummaryPanel(props: EstateSummaryPanelProps) {
: !props.summary.hasCanonicalProjection && props.summary.totalSystems > 0
? 'Syncing'
: 'Waiting for signal';
- const headlineDetail = () => {
- const issueText = dashboardIssueText(props);
- return issueText ? `${props.summary.detail} · ${issueText}` : props.summary.detail;
- };
return (
{props.summary.headline}
- · {headlineDetail()}
+ · {props.summary.detail}
+
+ ·
+
+
diff --git a/frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx b/frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx
new file mode 100644
index 000000000..93d5964ec
--- /dev/null
+++ b/frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx
@@ -0,0 +1,85 @@
+import { For } from 'solid-js';
+import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
+import { Card } from '@/components/shared/Card';
+import { AI_PATROL_PATH } from '@/routing/resourceLinks';
+import type { DashboardPulseBrief, DashboardPulseBriefTone } from './dashboardPulseBriefModel';
+import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
+import MessageCircleIcon from 'lucide-solid/icons/message-circle';
+
+interface PulseBriefPanelProps {
+ brief: DashboardPulseBrief;
+ onAskAssistant: () => void;
+}
+
+const toneClass: Record = {
+ healthy: 'border-l-emerald-500',
+ attention: 'border-l-amber-500',
+ critical: 'border-l-red-500',
+};
+
+const badgeClass: Record = {
+ healthy: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
+ attention: 'bg-amber-50 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
+ critical: 'bg-red-50 text-red-700 dark:bg-red-900/40 dark:text-red-300',
+};
+
+export function PulseBriefPanel(props: PulseBriefPanelProps) {
+ return (
+
+