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 ( + +
+
+
+ + + +
+

Pulse Brief

+

+ Patrol + Assistant +

+
+ + {props.brief.title} + +
+ +

{props.brief.body}

+ +
+ + {(item) => ( + + {item} + + )} + +
+
+ +
+ + + Open Patrol + +
+
+
+ ); +} diff --git a/frontend-modern/src/features/dashboardOverview/__tests__/dashboardPulseBriefModel.test.ts b/frontend-modern/src/features/dashboardOverview/__tests__/dashboardPulseBriefModel.test.ts new file mode 100644 index 000000000..e7611f17e --- /dev/null +++ b/frontend-modern/src/features/dashboardOverview/__tests__/dashboardPulseBriefModel.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import type { DashboardOverview } from '@/hooks/useDashboardOverview'; +import type { DashboardRecoverySummary } from '@/hooks/useDashboardRecovery'; +import { buildDashboardPulseBrief } from '../dashboardPulseBriefModel'; +import type { DashboardEstateSummary } from '../estateSummaryModel'; + +const estate = (overrides: Partial = {}): DashboardEstateSummary => ({ + hasCanonicalProjection: true, + totalSystems: 2, + activeSystems: 2, + healthySystems: 2, + degradedSystems: 0, + offlineSystems: 0, + unknownSystems: 0, + ignoredSystems: 0, + outdatedSystems: 0, + attentionSystems: 0, + headline: '2 systems reporting', + detail: '2 online', + tone: 'healthy', + surfaces: [], + systems: [], + ...overrides, +}); + +const overview = (overrides: Partial = {}): DashboardOverview => ({ + health: { + totalResources: 3, + byStatus: {}, + criticalAlerts: 0, + warningAlerts: 0, + }, + infrastructure: { + total: 2, + byStatus: {}, + byType: {}, + topCPU: [], + topMemory: [], + }, + workloads: { + total: 4, + running: 4, + stopped: 0, + byType: {}, + }, + storage: { + total: 1, + totalCapacity: 100, + totalUsed: 40, + warningCount: 0, + criticalCount: 0, + }, + alerts: { + activeCritical: 0, + activeWarning: 0, + total: 0, + }, + problemResources: [], + ...overrides, +}); + +const recovery = (overrides: Partial = {}): DashboardRecoverySummary => ({ + totalProtected: 2, + byOutcome: { success: 2 }, + latestEventTimestamp: Date.parse('2026-04-23T12:00:00Z'), + hasData: true, + ...overrides, +}); + +describe('dashboard Pulse Brief model', () => { + it('builds a steady operator brief from dashboard facts', () => { + const brief = buildDashboardPulseBrief({ + estate: estate(), + overview: overview(), + storageCapacityPercent: 40, + recovery: recovery(), + pendingApprovalCount: 0, + patrolFindingCount: 0, + }); + + expect(brief.tone).toBe('healthy'); + expect(brief.body).toContain('All 2 monitored systems are reporting cleanly'); + expect(brief.body).toContain('no pending approvals, active alerts, or Patrol findings'); + expect(brief.evidence).toContain('No active dashboard issues'); + expect(brief.assistantPrompt).toContain('Use only these dashboard facts'); + }); + + it('prioritizes concrete problem resources before lower-level context', () => { + const brief = buildDashboardPulseBrief({ + estate: estate({ headline: '1 system needs attention', attentionSystems: 1 }), + overview: overview({ + alerts: { activeCritical: 1, activeWarning: 1, total: 2 }, + problemResources: [ + { + resource: { + id: 'vm-101', + type: 'vm', + name: 'vm-101', + displayName: 'database-vm', + status: 'offline', + } as DashboardOverview['problemResources'][number]['resource'], + problems: ['Offline'], + worstValue: 200, + }, + ], + }), + storageCapacityPercent: 40, + recovery: recovery(), + pendingApprovalCount: 0, + patrolFindingCount: 1, + }); + + expect(brief.tone).toBe('critical'); + expect(brief.body).toContain('1 system needs attention'); + expect(brief.body).toContain('Review database-vm (Offline) first'); + expect(brief.evidence).toEqual( + expect.arrayContaining(['1 resource issue', '2 active alerts', '1 Patrol finding']), + ); + expect(brief.assistantContext.problemResources).toEqual([ + { id: 'vm-101', name: 'database-vm', problems: ['Offline'] }, + ]); + }); + + it('does not duplicate a problem reason that is already in the resource name', () => { + const brief = buildDashboardPulseBrief({ + estate: estate({ headline: '1 system needs attention', attentionSystems: 1 }), + overview: overview({ + problemResources: [ + { + resource: { + id: 'storage-offline', + type: 'storage', + name: 'storage (offline)', + displayName: 'storage (offline)', + status: 'offline', + } as DashboardOverview['problemResources'][number]['resource'], + problems: ['Offline'], + worstValue: 200, + }, + ], + }), + storageCapacityPercent: 40, + recovery: recovery(), + pendingApprovalCount: 0, + patrolFindingCount: 0, + }); + + expect(brief.body).toContain('Review storage (offline) first'); + expect(brief.body).not.toContain('storage (offline) (Offline)'); + }); +}); diff --git a/frontend-modern/src/features/dashboardOverview/dashboardPulseBriefModel.ts b/frontend-modern/src/features/dashboardOverview/dashboardPulseBriefModel.ts new file mode 100644 index 000000000..3aab96939 --- /dev/null +++ b/frontend-modern/src/features/dashboardOverview/dashboardPulseBriefModel.ts @@ -0,0 +1,209 @@ +import type { DashboardRecoverySummary } from '@/hooks/useDashboardRecovery'; +import type { DashboardOverview, ProblemResource } from '@/hooks/useDashboardOverview'; +import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity'; +import type { DashboardEstateSummary } from './estateSummaryModel'; + +export type DashboardPulseBriefTone = 'healthy' | 'attention' | 'critical'; + +export interface DashboardPulseBriefInput { + estate: DashboardEstateSummary; + overview: DashboardOverview; + storageCapacityPercent: number; + recovery: DashboardRecoverySummary; + pendingApprovalCount: number; + patrolFindingCount: number; +} + +export interface DashboardPulseBrief { + tone: DashboardPulseBriefTone; + title: string; + body: string; + evidence: string[]; + assistantPrompt: string; + assistantContext: Record; +} + +const pluralize = (count: number, singular: string, plural = `${singular}s`): string => + `${count} ${count === 1 ? singular : plural}`; + +function joinNatural(items: string[]): string { + if (items.length <= 1) return items[0] ?? ''; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; +} + +function normalizeCount(value: number | undefined): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value ?? 0)); +} + +function problemResourceLabel(problem: ProblemResource | undefined): string | null { + if (!problem) return null; + const name = getPreferredResourceDisplayName(problem.resource).trim(); + if (!name) return null; + const normalizedName = name.toLowerCase(); + const reasons = problem.problems + .filter((reason) => !normalizedName.includes(reason.toLowerCase())) + .slice(0, 2) + .join(', ') + .trim(); + return reasons ? `${name} (${reasons})` : name; +} + +function buildAttentionParts(input: DashboardPulseBriefInput): string[] { + const parts: string[] = []; + const resourceIssues = input.overview.problemResources.length; + const activeAlerts = normalizeCount(input.overview.alerts.total); + const storageWarnings = normalizeCount(input.overview.storage.warningCount); + const storageCritical = normalizeCount(input.overview.storage.criticalCount); + const recoveryFailures = + normalizeCount(input.recovery.byOutcome.failed) + + normalizeCount(input.recovery.byOutcome.error); + + if (resourceIssues > 0) parts.push(pluralize(resourceIssues, 'resource issue')); + if (activeAlerts > 0) parts.push(pluralize(activeAlerts, 'active alert')); + if (storageCritical > 0) parts.push(pluralize(storageCritical, 'critical storage signal')); + if (storageWarnings > 0) parts.push(pluralize(storageWarnings, 'storage warning')); + if (recoveryFailures > 0) parts.push(pluralize(recoveryFailures, 'recovery failure')); + if (input.pendingApprovalCount > 0) { + parts.push(pluralize(input.pendingApprovalCount, 'pending approval')); + } + if (input.patrolFindingCount > 0) { + parts.push(pluralize(input.patrolFindingCount, 'Patrol finding')); + } + + return parts; +} + +function buildBody(input: DashboardPulseBriefInput, attentionParts: string[]): string { + const systems = input.estate.totalSystems; + const storagePercent = Math.round(input.storageCapacityPercent); + const topProblem = problemResourceLabel(input.overview.problemResources[0]); + const activeAlerts = normalizeCount(input.overview.alerts.total); + const criticalAlerts = normalizeCount(input.overview.alerts.activeCritical); + const warningAlerts = normalizeCount(input.overview.alerts.activeWarning); + const recoveryFailures = + normalizeCount(input.recovery.byOutcome.failed) + + normalizeCount(input.recovery.byOutcome.error); + + const opening = + attentionParts.length > 0 + ? `${input.estate.headline}. Pulse sees ${joinNatural(attentionParts)} in the current dashboard signals.` + : `All ${pluralize(systems, 'monitored system')} ${ + systems === 1 ? 'is' : 'are' + } reporting cleanly across the current dashboard signals.`; + + const review = + topProblem !== null + ? `Review ${topProblem} first because it is the strongest resource-level signal.` + : criticalAlerts > 0 + ? `Start with ${pluralize(criticalAlerts, 'critical alert')} before reviewing lower-severity work.` + : activeAlerts > 0 + ? `There ${activeAlerts === 1 ? 'is' : 'are'} ${pluralize(warningAlerts || activeAlerts, 'warning alert')} to review.` + : input.pendingApprovalCount > 0 + ? `There ${input.pendingApprovalCount === 1 ? 'is' : 'are'} ${pluralize( + input.pendingApprovalCount, + 'Assistant approval', + )} waiting for a decision.` + : input.patrolFindingCount > 0 + ? `Pulse Patrol has ${pluralize(input.patrolFindingCount, 'finding')} waiting for review.` + : 'There are no pending approvals, active alerts, or Patrol findings waiting in the dashboard.'; + + const supporting = + input.overview.storage.criticalCount > 0 || input.overview.storage.warningCount > 0 + ? `Storage is at ${storagePercent}% capacity with storage health signals present.` + : recoveryFailures > 0 + ? `Recovery has ${pluralize(recoveryFailures, 'failed outcome')} in the latest rollup.` + : input.recovery.hasData + ? `Recovery is tracking ${pluralize(input.recovery.totalProtected, 'protected item')}.` + : 'Recovery has no protected-item rollup yet.'; + + return `${opening} ${review} ${supporting}`; +} + +function buildEvidence(input: DashboardPulseBriefInput, attentionParts: string[]): string[] { + const evidence = [ + pluralize(input.estate.totalSystems, 'system'), + pluralize(input.overview.workloads.total, 'workload'), + ]; + + if (attentionParts.length > 0) { + evidence.push(...attentionParts.slice(0, 3)); + } else { + evidence.push('No active dashboard issues'); + } + + if (input.recovery.hasData) { + evidence.push(pluralize(input.recovery.totalProtected, 'protected item')); + } + + return evidence.slice(0, 5); +} + +function buildAssistantPrompt(input: DashboardPulseBriefInput, body: string): string { + const topProblems = input.overview.problemResources + .slice(0, 3) + .map(problemResourceLabel) + .filter((label): label is string => label !== null); + + return [ + 'Summarize the current Pulse dashboard for an operator. Use only these dashboard facts unless you need to ask for more context.', + '', + `Current brief: ${body}`, + `Systems: ${input.estate.totalSystems} total, ${input.estate.healthySystems} healthy, ${input.estate.attentionSystems} needing attention.`, + `Alerts: ${input.overview.alerts.total} active, ${input.overview.alerts.activeCritical} critical, ${input.overview.alerts.activeWarning} warning.`, + `Storage: ${Math.round(input.storageCapacityPercent)}% capacity, ${input.overview.storage.criticalCount} critical signals, ${input.overview.storage.warningCount} warnings.`, + `Recovery: ${input.recovery.hasData ? `${input.recovery.totalProtected} protected items` : 'no rollup available'}.`, + topProblems.length > 0 + ? `Top problem resources: ${topProblems.join('; ')}.` + : 'Top problem resources: none.', + ].join('\n'); +} + +export function buildDashboardPulseBrief(input: DashboardPulseBriefInput): DashboardPulseBrief { + const attentionParts = buildAttentionParts(input); + const body = buildBody(input, attentionParts); + const hasCriticalSignals = + input.estate.offlineSystems > 0 || + input.overview.alerts.activeCritical > 0 || + input.overview.storage.criticalCount > 0 || + input.overview.problemResources.some((problem) => problem.worstValue >= 200); + const tone: DashboardPulseBriefTone = + hasCriticalSignals || input.pendingApprovalCount > 0 + ? 'critical' + : attentionParts.length > 0 + ? 'attention' + : 'healthy'; + + return { + tone, + title: tone === 'healthy' ? 'Estate looks steady' : 'Review recommended', + body, + evidence: buildEvidence(input, attentionParts), + assistantPrompt: buildAssistantPrompt(input, body), + assistantContext: { + dashboardBrief: body, + estate: { + totalSystems: input.estate.totalSystems, + healthySystems: input.estate.healthySystems, + attentionSystems: input.estate.attentionSystems, + }, + alerts: input.overview.alerts, + storage: { + capacityPercent: Math.round(input.storageCapacityPercent), + warningCount: input.overview.storage.warningCount, + criticalCount: input.overview.storage.criticalCount, + }, + recovery: input.recovery, + problemResources: input.overview.problemResources.slice(0, 3).map((problem) => ({ + id: problem.resource.id, + name: getPreferredResourceDisplayName(problem.resource), + problems: problem.problems, + })), + patrol: { + findingCount: input.patrolFindingCount, + pendingApprovalCount: input.pendingApprovalCount, + }, + }, + }; +} diff --git a/frontend-modern/src/features/dashboardOverview/dashboardSectionIds.ts b/frontend-modern/src/features/dashboardOverview/dashboardSectionIds.ts new file mode 100644 index 000000000..0c6b601b8 --- /dev/null +++ b/frontend-modern/src/features/dashboardOverview/dashboardSectionIds.ts @@ -0,0 +1,2 @@ +export const DASHBOARD_PROBLEM_RESOURCES_SECTION_ID = 'dashboard-problem-resources'; +export const DASHBOARD_ALERTS_SECTION_ID = 'dashboard-alerts'; diff --git a/frontend-modern/src/features/dashboardOverview/index.ts b/frontend-modern/src/features/dashboardOverview/index.ts index cc68e4c73..2a0127f5b 100644 --- a/frontend-modern/src/features/dashboardOverview/index.ts +++ b/frontend-modern/src/features/dashboardOverview/index.ts @@ -3,4 +3,6 @@ export { DashboardCustomizer } from './DashboardCustomizer'; export { EstateSummaryPanel } from './EstateSummaryPanel'; export { KPIStrip } from './KPIStrip'; export { ProblemResourcesTable } from './ProblemResourcesTable'; +export { PulseBriefPanel } from './PulseBriefPanel'; export { TrendCharts } from './TrendCharts'; +export { useDashboardPulseBrief } from './useDashboardPulseBrief'; diff --git a/frontend-modern/src/features/dashboardOverview/useDashboardPulseBrief.ts b/frontend-modern/src/features/dashboardOverview/useDashboardPulseBrief.ts new file mode 100644 index 000000000..511c6b440 --- /dev/null +++ b/frontend-modern/src/features/dashboardOverview/useDashboardPulseBrief.ts @@ -0,0 +1,41 @@ +import { createEffect, createMemo, type Accessor } from 'solid-js'; +import type { DashboardRecoverySummary } from '@/hooks/useDashboardRecovery'; +import type { DashboardOverview } from '@/hooks/useDashboardOverview'; +import { aiRuntimeSettings, loadAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import { hasFeature } from '@/stores/license'; +import type { DashboardEstateSummary } from './estateSummaryModel'; +import { buildDashboardPulseBrief, type DashboardPulseBrief } from './dashboardPulseBriefModel'; + +interface UseDashboardPulseBriefInput { + estate: Accessor; + overview: Accessor; + storageCapacityPercent: Accessor; + recovery: Accessor; + pendingApprovalCount: Accessor; + patrolFindingCount: Accessor; +} + +export function useDashboardPulseBrief( + input: UseDashboardPulseBriefInput, +): Accessor { + createEffect(() => { + if (!hasFeature('ai_patrol')) return; + void loadAIRuntimeSettings().catch(() => undefined); + }); + + return createMemo(() => { + const settings = aiRuntimeSettings(); + if (!hasFeature('ai_patrol') || !settings?.enabled || !settings.configured) { + return null; + } + + return buildDashboardPulseBrief({ + estate: input.estate(), + overview: input.overview(), + storageCapacityPercent: input.storageCapacityPercent(), + recovery: input.recovery(), + pendingApprovalCount: input.pendingApprovalCount(), + patrolFindingCount: input.patrolFindingCount(), + }); + }); +} diff --git a/frontend-modern/src/pages/Dashboard.tsx b/frontend-modern/src/pages/Dashboard.tsx index 5320cbd3a..4e4d3c1fd 100644 --- a/frontend-modern/src/pages/Dashboard.tsx +++ b/frontend-modern/src/pages/Dashboard.tsx @@ -30,9 +30,16 @@ import { EstateSummaryPanel, KPIStrip, ProblemResourcesTable, + PulseBriefPanel, TrendCharts, + useDashboardPulseBrief, } from '@/features/dashboardOverview'; import { buildDashboardEstateSummary } from '@/features/dashboardOverview/estateSummaryModel'; +import { + DASHBOARD_ALERTS_SECTION_ID, + DASHBOARD_PROBLEM_RESOURCES_SECTION_ID, +} from '@/features/dashboardOverview/dashboardSectionIds'; +import { aiChatStore } from '@/stores/aiChat'; import { RecentAlertsPanel } from '@/components/Alerts/RecentAlertsPanel'; import { DashboardRecoveryStatusPanel } from '@/components/Recovery/DashboardRecoveryStatusPanel'; import { DashboardStoragePanel } from '@/components/Storage/DashboardStoragePanel'; @@ -125,6 +132,26 @@ export default function Dashboard() { return Math.max(0, Math.min(100, (totalUsed / totalCapacity) * 100)); }); + const pulseBrief = useDashboardPulseBrief({ + estate: estateSummary, + overview, + storageCapacityPercent, + recovery: recoverySummary, + pendingApprovalCount: () => actions.pendingApprovals().length, + patrolFindingCount: () => actions.findingsNeedingAttention().length, + }); + + const openPulseBriefAssistant = () => { + const brief = pulseBrief(); + if (!brief) return; + aiChatStore.open({ + targetType: 'dashboard', + targetId: 'pulse-brief', + initialPrompt: brief.assistantPrompt, + context: brief.assistantContext, + }); + }; + const renderWidget = (id: DashboardWidgetId) => { switch (id) { case 'trends': @@ -137,7 +164,16 @@ export default function Dashboard() { /> ); case 'alerts': - return ; + return ( +
+ +
+ ); case 'recovery': return ; case 'storage': @@ -307,7 +343,14 @@ export default function Dashboard() { activeAlertCount={overview().alerts.total} /> - {/* 3. KPI Strip — always visible */} + {/* 3. Optional Pulse Brief — shown only when Assistant and Patrol are configured */} + + {(brief) => ( + + )} + + + {/* 4. KPI Strip — always visible */} - {/* 4. Problem Resources Table — only when problems exist */} - + {/* 5. Problem Resources Table — only when problems exist */} + 0}> +
+ +
+
- {/* 5–6. Customizable widgets: Trend Charts, Recent Alerts */} + {/* 6–7. Customizable widgets: Trend Charts, Recent Alerts */} {(group) => group.type === 'full' ? ( diff --git a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx index 6740bb43c..1dfef126a 100644 --- a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx @@ -1,10 +1,21 @@ import { fireEvent, render, screen } from '@solidjs/testing-library'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DASHBOARD_ALERTS_SECTION_ID, + DASHBOARD_PROBLEM_RESOURCES_SECTION_ID, +} from '@/features/dashboardOverview/dashboardSectionIds'; import type { DashboardOverview } from '@/hooks/useDashboardOverview'; import type { DashboardRecoverySummary } from '@/hooks/useDashboardRecovery'; import DashboardPage from '@/pages/Dashboard'; import dashboardPageSource from '@/pages/Dashboard.tsx?raw'; +const aiRuntimeMock = vi.hoisted(() => ({ + featureEnabled: false, + settings: null as unknown, + loadSettings: vi.fn(async () => null), + openChat: vi.fn(), +})); + let overviewLoading = false; let overviewError: unknown = undefined; let wsConnected = true; @@ -85,6 +96,21 @@ vi.mock('@solidjs/router', async () => { }; }); +vi.mock('@/stores/license', () => ({ + hasFeature: (feature: string) => feature === 'ai_patrol' && aiRuntimeMock.featureEnabled, +})); + +vi.mock('@/stores/aiRuntimeState', () => ({ + aiRuntimeSettings: () => aiRuntimeMock.settings, + loadAIRuntimeSettings: () => aiRuntimeMock.loadSettings(), +})); + +vi.mock('@/stores/aiChat', () => ({ + aiChatStore: { + open: (context: unknown) => aiRuntimeMock.openChat(context), + }, +})); + vi.mock('@/hooks/useDashboardOverview', () => ({ useDashboardOverview: () => ({ overview: () => overviewMock, @@ -131,6 +157,10 @@ describe('Dashboard page module contract', () => { wsReconnecting = false; reconnectSpy.mockReset(); navigateSpy.mockReset(); + aiRuntimeMock.featureEnabled = false; + aiRuntimeMock.settings = null; + aiRuntimeMock.loadSettings.mockClear(); + aiRuntimeMock.openChat.mockClear(); overviewMock.health.totalResources = 0; overviewMock.infrastructure.total = 0; overviewMock.infrastructure.byStatus = {}; @@ -147,6 +177,7 @@ describe('Dashboard page module contract', () => { overviewMock.alerts.activeWarning = 0; overviewMock.alerts.total = 0; connectedInfrastructureMock.length = 0; + window.history.replaceState(null, '', '/'); }); it('exports a default component function', () => { @@ -155,7 +186,8 @@ describe('Dashboard page module contract', () => { it('routes the alerts dashboard widget through the alert-owned surface', () => { expect(dashboardPageSource).toContain("from '@/components/Alerts/RecentAlertsPanel'"); - expect(dashboardPageSource).toContain('return ;'); + expect(dashboardPageSource).toContain(''); + expect(dashboardPageSource).toContain('id={DASHBOARD_ALERTS_SECTION_ID}'); expect(dashboardPageSource).not.toContain('criticalCount={overview().alerts.activeCritical}'); expect(dashboardPageSource).not.toContain('warningCount={overview().alerts.activeWarning}'); }); @@ -165,7 +197,7 @@ describe('Dashboard page module contract', () => { expect(dashboardPageSource).not.toContain("from '@/components/Dashboard/RelayOnboardingCard'"); expect(dashboardPageSource).not.toContain(''); expect(dashboardPageSource).toContain( - 'ActionRequiredPanel,\n DashboardCustomizer,\n EstateSummaryPanel,\n KPIStrip,\n ProblemResourcesTable,\n TrendCharts,', + 'ActionRequiredPanel,\n DashboardCustomizer,\n EstateSummaryPanel,\n KPIStrip,\n ProblemResourcesTable,\n PulseBriefPanel,\n TrendCharts,\n useDashboardPulseBrief,', ); }); @@ -282,7 +314,14 @@ describe('Dashboard page module contract', () => { const problemHeading = screen.getByRole('heading', { name: 'Problem Resources' }); expect(screen.getByText('1 system needs attention')).toBeInTheDocument(); - expect(screen.getAllByText(/1 resource issue below/).length).toBeGreaterThan(0); + expect(screen.getByRole('link', { name: '1 resource issue' })).toHaveAttribute( + 'href', + `#${DASHBOARD_PROBLEM_RESOURCES_SECTION_ID}`, + ); + expect(screen.getByTestId('dashboard-problem-resources-section')).toHaveAttribute( + 'id', + DASHBOARD_PROBLEM_RESOURCES_SECTION_ID, + ); expect(screen.getByText('1 system needs review; details below')).toBeInTheDocument(); expect(screen.getByText('2 active')).toBeInTheDocument(); expect(screen.getByText(/Proxmox/)).toBeInTheDocument(); @@ -327,9 +366,110 @@ describe('Dashboard page module contract', () => { render(() => ); expect(screen.getByText('1 system reporting')).toBeInTheDocument(); - expect(screen.getAllByText(/1 resource issue and 2 alerts below/).length).toBeGreaterThan(0); + const resourceIssueLink = screen.getByRole('link', { name: '1 resource issue' }); + const alertIssueLink = screen.getByRole('link', { name: '2 alerts' }); + expect(resourceIssueLink).toHaveAttribute('href', `#${DASHBOARD_PROBLEM_RESOURCES_SECTION_ID}`); + expect(alertIssueLink).toHaveAttribute('href', `#${DASHBOARD_ALERTS_SECTION_ID}`); + expect(screen.getByTestId('dashboard-alerts-section')).toHaveAttribute( + 'id', + DASHBOARD_ALERTS_SECTION_ID, + ); expect(screen.getByText('Resource issues and alerts listed below')).toBeInTheDocument(); expect(screen.queryByText('No dashboard issues found')).toBeNull(); + + const previousScrollIntoView = HTMLElement.prototype.scrollIntoView; + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + try { + fireEvent.click(resourceIssueLink); + expect(window.location.hash).toBe(`#${DASHBOARD_PROBLEM_RESOURCES_SECTION_ID}`); + expect(scrollIntoView).toHaveBeenCalled(); + expect(document.activeElement).toBe( + screen.getByTestId('dashboard-problem-resources-section'), + ); + } finally { + HTMLElement.prototype.scrollIntoView = previousScrollIntoView; + } + }); + + it('shows the optional Pulse Brief when Assistant and Patrol are configured', async () => { + aiRuntimeMock.featureEnabled = true; + aiRuntimeMock.settings = { + enabled: true, + configured: true, + model: 'test-model', + custom_context: '', + auth_method: 'api_key', + oauth_connected: false, + anthropic_configured: false, + openai_configured: true, + openrouter_configured: false, + deepseek_configured: false, + gemini_configured: false, + ollama_configured: false, + ollama_base_url: '', + configured_providers: ['openai'], + }; + overviewMock.health.totalResources = 5; + overviewMock.infrastructure.total = 1; + overviewMock.infrastructure.byStatus = { online: 1 }; + overviewMock.workloads.total = 4; + overviewMock.workloads.running = 3; + overviewMock.alerts.activeCritical = 1; + overviewMock.alerts.total = 1; + connectedInfrastructureMock.push({ + id: 'homelab', + name: 'homelab', + status: 'active', + healthStatus: 'online', + lastSeen: Date.now(), + surfaces: [{ id: 'agent:homelab', kind: 'agent', label: 'Host telemetry' }], + }); + overviewMock.problemResources = [ + { + resource: { + id: 'container-1', + type: 'app-container', + name: 'Container 1', + displayName: 'Container 1', + platformId: 'container-1', + platformType: 'docker', + sourceType: 'api', + status: 'offline', + } as DashboardOverview['problemResources'][number]['resource'], + problems: ['Offline'], + worstValue: 200, + }, + ]; + + render(() => ); + + const brief = screen.getByTestId('dashboard-pulse-brief'); + const estateHeading = screen.getByRole('heading', { name: 'Connected infrastructure' }); + const kpiLabel = screen.getByText('Infrastructure'); + + expect(brief).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Pulse Brief' })).toBeInTheDocument(); + expect(screen.getByText(/Review Container 1 \(Offline\) first/)).toBeInTheDocument(); + expect(estateHeading.compareDocumentPosition(brief) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(brief.compareDocumentPosition(kpiLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Ask Assistant' })); + + expect(aiRuntimeMock.openChat).toHaveBeenCalledWith( + expect.objectContaining({ + targetType: 'dashboard', + targetId: 'pulse-brief', + initialPrompt: expect.stringContaining('Summarize the current Pulse dashboard'), + context: expect.objectContaining({ + dashboardBrief: expect.stringContaining('Review Container 1 (Offline) first'), + }), + }), + ); }); it('keeps the KPI strip above problem resources so the dashboard snapshot reads before detail', () => {