mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Add dashboard Pulse Brief
This commit is contained in:
parent
5631ed0c3a
commit
25dc7b901e
13 changed files with 802 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<a href={props.href} onClick={focusDashboardSection} class={sectionLinkClass}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<DashboardSectionLink href={`#${DASHBOARD_PROBLEM_RESOURCES_SECTION_ID}`}>
|
||||
{pluralize(resourceIssueCount, 'resource issue')}
|
||||
</DashboardSectionLink>
|
||||
<span> and </span>
|
||||
<DashboardSectionLink href={`#${DASHBOARD_ALERTS_SECTION_ID}`}>
|
||||
{pluralize(activeAlertCount, 'alert')}
|
||||
</DashboardSectionLink>
|
||||
<span> below</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (resourceIssueCount > 0) {
|
||||
return (
|
||||
<>
|
||||
<DashboardSectionLink href={`#${DASHBOARD_PROBLEM_RESOURCES_SECTION_ID}`}>
|
||||
{pluralize(resourceIssueCount, 'resource issue')}
|
||||
</DashboardSectionLink>
|
||||
<span> below</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (activeAlertCount > 0) {
|
||||
return (
|
||||
<>
|
||||
<DashboardSectionLink href={`#${DASHBOARD_ALERTS_SECTION_ID}`}>
|
||||
{pluralize(activeAlertCount, 'alert')}
|
||||
</DashboardSectionLink>
|
||||
<span> below</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Card
|
||||
|
|
@ -118,7 +188,11 @@ export function EstateSummaryPanel(props: EstateSummaryPanelProps) {
|
|||
<span class={`font-medium ${toneTextClass[props.summary.tone]}`}>
|
||||
{props.summary.headline}
|
||||
</span>
|
||||
<span> · {headlineDetail()}</span>
|
||||
<span> · {props.summary.detail}</span>
|
||||
<Show when={hasDashboardIssueLinks(props)}>
|
||||
<span> · </span>
|
||||
<DashboardIssueLinks {...props} />
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<DashboardPulseBriefTone, string> = {
|
||||
healthy: 'border-l-emerald-500',
|
||||
attention: 'border-l-amber-500',
|
||||
critical: 'border-l-red-500',
|
||||
};
|
||||
|
||||
const badgeClass: Record<DashboardPulseBriefTone, string> = {
|
||||
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 (
|
||||
<Card
|
||||
padding="none"
|
||||
class={`overflow-hidden border-l-[3px] ${toneClass[props.brief.tone]}`}
|
||||
data-testid="dashboard-pulse-brief"
|
||||
>
|
||||
<div class="flex flex-col gap-3 px-4 py-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-cyan-50 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
|
||||
<PulsePatrolLogo class="h-4 w-4" title="Pulse Brief" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold text-base-content">Pulse Brief</h2>
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Patrol + Assistant
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class={`rounded px-2 py-0.5 text-[11px] font-medium ${badgeClass[props.brief.tone]}`}
|
||||
>
|
||||
{props.brief.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 max-w-5xl text-sm leading-6 text-base-content">{props.brief.body}</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<For each={props.brief.evidence}>
|
||||
{(item) => (
|
||||
<span class="rounded border border-border-subtle bg-base px-2 py-0.5 text-[11px] text-muted">
|
||||
{item}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onAskAssistant()}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<MessageCircleIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Ask Assistant
|
||||
</button>
|
||||
<a
|
||||
href={AI_PATROL_PATH}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-base-content hover:bg-surface-hover"
|
||||
>
|
||||
Open Patrol
|
||||
<ArrowRightIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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> = {}): 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> = {}): 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> = {}): 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)');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const DASHBOARD_PROBLEM_RESOURCES_SECTION_ID = 'dashboard-problem-resources';
|
||||
export const DASHBOARD_ALERTS_SECTION_ID = 'dashboard-alerts';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<DashboardEstateSummary>;
|
||||
overview: Accessor<DashboardOverview>;
|
||||
storageCapacityPercent: Accessor<number>;
|
||||
recovery: Accessor<DashboardRecoverySummary>;
|
||||
pendingApprovalCount: Accessor<number>;
|
||||
patrolFindingCount: Accessor<number>;
|
||||
}
|
||||
|
||||
export function useDashboardPulseBrief(
|
||||
input: UseDashboardPulseBriefInput,
|
||||
): Accessor<DashboardPulseBrief | null> {
|
||||
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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 <RecentAlertsPanel alerts={alertsList()} />;
|
||||
return (
|
||||
<section
|
||||
id={DASHBOARD_ALERTS_SECTION_ID}
|
||||
data-testid="dashboard-alerts-section"
|
||||
tabIndex={-1}
|
||||
class="scroll-mt-4 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50"
|
||||
>
|
||||
<RecentAlertsPanel alerts={alertsList()} />
|
||||
</section>
|
||||
);
|
||||
case 'recovery':
|
||||
return <DashboardRecoveryStatusPanel recovery={recoverySummary()} />;
|
||||
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 */}
|
||||
<Show when={pulseBrief()}>
|
||||
{(brief) => (
|
||||
<PulseBriefPanel brief={brief()} onAskAssistant={openPulseBriefAssistant} />
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* 4. KPI Strip — always visible */}
|
||||
<KPIStrip
|
||||
infrastructure={{
|
||||
total: estateSummary().totalSystems,
|
||||
|
|
@ -329,10 +372,19 @@ export default function Dashboard() {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* 4. Problem Resources Table — only when problems exist */}
|
||||
<ProblemResourcesTable problems={overview().problemResources} />
|
||||
{/* 5. Problem Resources Table — only when problems exist */}
|
||||
<Show when={overview().problemResources.length > 0}>
|
||||
<section
|
||||
id={DASHBOARD_PROBLEM_RESOURCES_SECTION_ID}
|
||||
data-testid="dashboard-problem-resources-section"
|
||||
tabIndex={-1}
|
||||
class="scroll-mt-4 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50"
|
||||
>
|
||||
<ProblemResourcesTable problems={overview().problemResources} />
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
{/* 5–6. Customizable widgets: Trend Charts, Recent Alerts */}
|
||||
{/* 6–7. Customizable widgets: Trend Charts, Recent Alerts */}
|
||||
<For each={widgetGroups()}>
|
||||
{(group) =>
|
||||
group.type === 'full' ? (
|
||||
|
|
|
|||
|
|
@ -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 <RecentAlertsPanel alerts={alertsList()} />;');
|
||||
expect(dashboardPageSource).toContain('<RecentAlertsPanel alerts={alertsList()} />');
|
||||
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('<RelayOnboardingCard />');
|
||||
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(() => <DashboardPage />);
|
||||
|
||||
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(() => <DashboardPage />);
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue