Improve monitor-first frontend workflows

This commit is contained in:
rcourtman
2026-07-09 23:26:53 +01:00
parent 255c7c23d4
commit 20b3102015
33 changed files with 472 additions and 271 deletions
@@ -148,7 +148,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<div class="flex items-start gap-2.5">
<TriangleAlertIcon class="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<div class="text-xs text-amber-800 dark:text-amber-200">
<p class="mb-1 font-medium">Service Context Disabled</p>
<p class="mb-1 font-medium">Service identification is off</p>
<p class="text-amber-700 dark:text-amber-300">
Enable service context to inspect what is running on this resource.
</p>
@@ -88,7 +88,7 @@ describe('DiscoveryTab', () => {
<DiscoveryTab resourceType="agent" agentId="agent-1" resourceId="agent-1" hostname="pve1" />
));
expect(await screen.findByText('Service Context Disabled')).toBeInTheDocument();
expect(await screen.findByText('Service identification is off')).toBeInTheDocument();
expect(discoveryApi.getDiscovery).not.toHaveBeenCalled();
expect(discoveryApi.getDiscoveryInfo).not.toHaveBeenCalled();
expect(discoveryApi.getConnectedAgents).not.toHaveBeenCalled();
@@ -15,7 +15,7 @@ export type DiscoveryReadinessStatus =
| 'ready';
export interface DiscoveryReadinessInputs {
/** The service context toggle (Settings -> Pulse Intelligence -> Service Context). */
/** The service context toggle (Settings -> Pulse Intelligence -> Assistant). */
discoveryEnabled: boolean;
/** Whether at least one AI provider has credentials configured. */
aiProviderConfigured: boolean;
@@ -198,9 +198,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
const handleTriggerDiscovery = async (force = false) => {
if (!discoveryFeatureEnabled()) {
setScanError(
'Service context is disabled in Settings -> Pulse Intelligence -> Service Context.',
);
setScanError('Service context is disabled in Settings -> Pulse Intelligence -> Assistant.');
return;
}
@@ -38,6 +38,7 @@ interface ResourceDetailDrawerProps {
presentation?: ResourceDetailDrawerPresentation;
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
initialShowTrueNASDetails?: boolean;
onResourceActionSettled?: () => void | Promise<void>;
}
@@ -69,6 +70,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
presentation: presentation(),
resolveResourceLabel: props.resolveResourceLabel,
initialShowAccessContext: props.initialShowAccessContext,
initialShowHostDetails: props.initialShowHostDetails,
initialShowTrueNASDetails: props.initialShowTrueNASDetails,
});
const headingId = () => `resource-detail-drawer-heading-${props.resource.id}`;
@@ -354,6 +356,7 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
presentation={props.presentation}
resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext}
initialShowHostDetails={props.initialShowHostDetails}
initialShowTrueNASDetails={props.initialShowTrueNASDetails}
onResourceActionSettled={props.onResourceActionSettled}
/>
@@ -358,14 +358,14 @@ describe('toDiscoveryConfig', () => {
});
describe('resource drawer discovery promotion', () => {
it('points disabled discovery readiness at Pulse Intelligence service context settings', () => {
expect(discoveryTabSource).toContain('Service Context Disabled');
it('points disabled discovery readiness at Assistant-owned service identification settings', () => {
expect(discoveryTabSource).toContain('Service identification is off');
expect(discoveryTabSource).toContain('getDiscoveryServiceContextSettingsTarget');
expect(discoveryTabSource).toContain('serviceContextSettingsTarget.label');
expect(discoveryTabStateSource).toContain(
'Service context is disabled in Settings -> Pulse Intelligence -> Service Context.',
'Service context is disabled in Settings -> Pulse Intelligence -> Assistant.',
);
expect(discoveryReadinessSource).toContain('Settings -> Pulse Intelligence -> Service Context');
expect(discoveryReadinessSource).toContain('Settings -> Pulse Intelligence -> Assistant');
});
it('does not treat command-only diagnostic records as meaningful resource context', () => {
@@ -17,20 +17,14 @@ import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextM
import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
type DrawerTab =
| 'overview'
| 'history'
| 'discovery'
| 'mail'
| 'namespaces'
| 'deployments'
| 'swarm'
| 'debug';
'overview' | 'history' | 'discovery' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
export interface UseResourceDetailDrawerStateOptions {
resource: Resource;
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
presentation?: ResourceDetailDrawerPresentation;
initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
initialShowTrueNASDetails?: boolean;
}
@@ -52,7 +46,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
const [showInvestigationContext, setShowInvestigationContext] = createSignal(false);
const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false);
const [showHostDetails, setShowHostDetails] = createSignal(
options.presentation === 'table-row' && isPulseAgentPlatformResource(resource),
options.initialShowHostDetails ??
(options.presentation === 'table-row' && isPulseAgentPlatformResource(resource)),
);
const [showServiceDetails, setShowServiceDetails] = createSignal(false);
const [showVMwareDetails, setShowVMwareDetails] = createSignal(false);
@@ -267,6 +267,22 @@ const AssistantSettingsContent: Component<{ state: ReturnType<typeof useAISettin
<AIModelOverrideField state={props.state} kind="assistant" />
<AIAssistantCommandAccessSection state={props.state} />
<AIChatMaintenanceSection state={props.state} />
<details class="overflow-hidden rounded-md border border-border bg-surface-alt/40">
<summary class="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-base-content hover:bg-surface-hover">
Service identification
<span class="ml-2 font-normal text-muted">
Model-backed context used by Assistant and Patrol
</span>
</summary>
<div class="space-y-4 border-t border-border bg-surface p-4">
<p class="text-xs leading-5 text-muted">
Identify service facts that help Assistant and Patrol explain monitored resources.
Infrastructure discovery and onboarding remain under Infrastructure.
</p>
<AIModelOverrideField state={props.state} kind="discovery" />
<AIDiscoveryControlsSection state={props.state} />
</div>
</details>
</div>
);
@@ -1,4 +1,12 @@
import { Component, createSignal, onMount, Show, Suspense, createMemo } from 'solid-js';
import {
Component,
createEffect,
createSignal,
onMount,
Show,
Suspense,
createMemo,
} from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { useNavigate, useLocation } from '@solidjs/router';
import { useWebSocket } from '@/contexts/appRuntime';
@@ -147,6 +155,11 @@ const SettingsWorkspace: Component<SettingsProps> = (props) => {
return settingsPanelRegistry()[currentTab];
});
createEffect(() => {
activeTab();
queueMicrotask(() => window.scrollTo({ top: 0, behavior: 'auto' }));
});
onMount(() => {
void loadRuntimeCapabilities();
});
@@ -34,7 +34,8 @@ describe('settings localization catalog', () => {
expect(groups[0]?.label).toBe('Infrastruktur');
expect(groups[0]?.items[0]?.label).toBe('Infrastruktur');
expect(getSettingsNavItem('system-updates', 'de')?.label).toBe('Updates');
expect(getSettingsNavItem('system-ai-discovery', 'de')?.label).toBe('Service-Kontext');
expect(getSettingsNavItem('system-ai-assistant', 'de')?.label).toBe('Assistant');
expect(getSettingsNavItem('system-ai-discovery', 'de')).toBeUndefined();
expect(getSettingsNavItem('security-data-handling', 'de')?.label).toBe('Ressourcenschutz');
});
@@ -44,7 +45,7 @@ describe('settings localization catalog', () => {
expect(getSettingsNavItem('system-ai', 'es')?.label).toBe('Proveedores y modelos');
expect(getSettingsNavItem('system-ai-patrol', 'es')?.label).toBe('Patrol');
expect(getSettingsNavItem('system-ai-assistant', 'es')?.label).toBe('Assistant');
expect(getSettingsNavItem('system-ai-discovery', 'es')?.label).toBe('Contexto de servicio');
expect(getSettingsNavItem('system-ai-discovery', 'es')).toBeUndefined();
expect(getSettingsNavItem('support-diagnostics', 'es')?.label).toBe('Diagnóstico y salud');
});
@@ -394,13 +394,17 @@ describe('settingsNavigation integration scaffold', () => {
for (const [tab, path] of Object.entries(dispatchableCanonicalTabPaths) as Array<
[SettingsTab, string]
>) {
expect(deriveTabFromPath(path)).toBe(tab);
expect(deriveTabFromPath(path)).toBe(
tab === 'system-ai-discovery' ? 'system-ai-assistant' : tab,
);
}
});
it('round-trips settingsTabPath through deriveTabFromPath', () => {
for (const tab of Object.keys(dispatchableCanonicalTabPaths) as SettingsTab[]) {
expect(deriveTabFromPath(settingsTabPath(tab))).toBe(tab);
expect(deriveTabFromPath(settingsTabPath(tab))).toBe(
tab === 'system-ai-discovery' ? 'system-ai-assistant' : tab,
);
}
});
@@ -414,7 +418,7 @@ describe('settingsNavigation integration scaffold', () => {
for (const tab of Object.keys(dispatchableCanonicalTabPaths) as SettingsTab[]) {
const path = settingsTabPath(tab);
const derived = deriveTabFromPath(path);
expect(derived).toBe(tab);
expect(derived).toBe(tab === 'system-ai-discovery' ? 'system-ai-assistant' : tab);
}
});
@@ -205,7 +205,7 @@ describe('settingsNavigationModel', () => {
['?tab=system-ai', 'system-ai'],
['?tab=patrol', 'system-ai-patrol'],
['?tab=assistant', 'system-ai-assistant'],
['?tab=discovery', 'system-ai-discovery'],
['?tab=discovery', 'system-ai-assistant'],
['?ai_oauth_error=unsupported', 'system-ai'],
['?ai_oauth_success=true', 'system-ai'],
['?tab=system-relay', 'system-relay'],
@@ -81,12 +81,6 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: Terminal,
iconProps: { strokeWidth: 2 },
},
{
id: 'system-ai-discovery',
label: 'Service Context',
icon: Network,
iconProps: { strokeWidth: 2 },
},
],
},
{
@@ -196,6 +196,9 @@ export function resolveCanonicalSettingsPath(path: string): string | null {
) {
return settingsTabPath('system-ai');
}
if (normalizedPath === PULSE_INTELLIGENCE_DISCOVERY_PREFIX) {
return settingsTabPath('system-ai-assistant');
}
if (normalizedPath === SUPPORT_PREFIX) {
return SUPPORT_DIAGNOSTICS_PREFIX;
}
@@ -327,7 +330,7 @@ export function deriveTabFromQuery(search: string): SettingsTab | null {
return 'system-ai-assistant';
case 'system-ai-discovery':
case 'discovery':
return 'system-ai-discovery';
return 'system-ai-assistant';
case 'system-relay':
return 'system-relay';
case 'system-billing':
@@ -101,11 +101,11 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
<A
href={providerSetupAction.href}
aria-label={`Check Patrol model: ${state.triggerPatrolDisabledReason() || 'Patrol model needs attention'}`}
title={state.triggerPatrolDisabledReason() || 'Open Provider & Models'}
title={state.triggerPatrolDisabledReason() || 'Open Patrol settings'}
class={className}
>
<SettingsIcon class="w-4 h-4" />
Check model
Fix setup
</A>
}
>
@@ -347,15 +347,17 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
'sm:hidden flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted rounded-md transition-colors',
)}
<A
href={settingsTabPath('system-ai-patrol')}
aria-label="Open Patrol settings"
title="Open Patrol settings"
class="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt"
>
<SettingsIcon class="w-4 h-4" />
<span class="sr-only sm:not-sr-only">Settings</span>
</A>
<Show when={!runBlockedByProviderSetup()}>
<A
href={settingsTabPath('system-ai-patrol')}
aria-label="Open Patrol settings"
title="Open Patrol settings"
class="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt"
>
<SettingsIcon class="w-4 h-4" />
<span class="sr-only sm:not-sr-only">Settings</span>
</A>
</Show>
</div>
</Show>
</div>
@@ -1,6 +1,8 @@
import { createMemo, For, Show } from 'solid-js';
import HistoryIcon from 'lucide-solid/icons/history';
import SettingsIcon from 'lucide-solid/icons/settings';
import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
import ListChecksIcon from 'lucide-solid/icons/list-checks';
import { FindingsPanel } from '@/components/AI/FindingsPanel';
import { ApprovalBanner, RunHistoryPanel } from '@/components/patrol';
import {
@@ -136,8 +138,7 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
}),
);
const hasVisibleWorkGroups = () =>
queueIssueCount() > 0 ||
workGroupSummaries().some((group) => group.id !== 'stale-protection');
queueIssueCount() > 0 || workGroupSummaries().some((group) => group.id !== 'stale-protection');
const shouldShowWorkGroups = () =>
!isHistoryOpen() && !isSetupOnly() && !state.selectedRun() && hasVisibleWorkGroups();
@@ -178,7 +179,7 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
</div>
<p class="mt-1 text-xs text-muted">{workspaceDescription()}</p>
</div>
<Show when={!isSetupOnly()}>
<Show when={!isSetupOnly() || (state.patrolRunHistory.value()?.length ?? 0) > 0}>
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
@@ -309,18 +310,41 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
/>
}
>
<div class="rounded-md border border-border bg-surface-alt p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 max-w-2xl">
<h3 class="text-sm font-semibold text-base-content">
Patrol model needs attention
</h3>
<p class="mt-2 text-xs text-muted">Patrol check: {setupReason()}</p>
<div class="overflow-hidden rounded-md border border-amber-300 bg-amber-50/70 dark:border-amber-800 dark:bg-amber-950/20">
<div class="grid gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.7fr)] lg:p-5">
<div class="min-w-0">
<div class="flex items-center gap-2 text-amber-800 dark:text-amber-200">
<ShieldAlertIcon class="h-5 w-5 shrink-0" aria-hidden="true" />
<h3 class="text-sm font-semibold">Patrol cannot run yet</h3>
</div>
<p class="mt-2 text-sm leading-6 text-base-content">{setupReason()}</p>
<p class="mt-1 text-xs leading-5 text-muted">
Open Patrol settings and run the model check. Provider connectivity can be healthy
even when the selected model cannot use Patrol tools.
</p>
<ButtonLink
href={setupAction.href}
variant="primary"
size="sm"
class="mt-4 gap-1.5"
>
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
{setupAction.label}
</ButtonLink>
</div>
<div class="rounded-md border border-amber-200 bg-surface/80 p-3 dark:border-amber-900">
<div class="flex items-center gap-2">
<ListChecksIcon class="h-4 w-4 text-muted" aria-hidden="true" />
<p class="text-xs font-semibold uppercase tracking-wider text-muted">
Once ready
</p>
</div>
<ul class="mt-2 space-y-2 text-xs leading-5 text-muted">
<li>Patrol checks monitored infrastructure for current issues.</li>
<li>Findings, approvals, and verification stay together in Open work.</li>
<li>Past checks remain available from History.</li>
</ul>
</div>
<ButtonLink href={setupAction.href} variant="primary" size="sm" class="gap-1.5">
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
{setupAction.label}
</ButtonLink>
</div>
</div>
</Show>
@@ -65,14 +65,14 @@ describe('PatrolIntelligenceWorkspace trust strip', () => {
'Open work Patrol can ' +
'watch, investigate, ask you to approve, or record under your Patrol mode.';
expect(workspaceSource).not.toContain(removedAllModeCopy);
expect(workspaceSource).toContain('Patrol model needs attention');
expect(workspaceSource).toContain('Patrol check:');
expect(workspaceSource).toContain('Patrol cannot run yet');
expect(workspaceSource).toContain('Once ready');
expect(workspaceSource).toContain('getPatrolProviderSettingsAction');
expect(workspaceSource).toContain('<Show when={!isSetupOnly()}>');
expect(workspaceSource).toContain('state.patrolRunHistory.value()?.length');
expect(workspaceSource).not.toContain('showControls={!state.selectedRun() && !isSetupOnly()}');
expect(PATROL_WORKSPACE_SETUP_TITLE).toBe('Check Patrol model');
expect(PATROL_WORKSPACE_SETUP_TITLE).toBe('Patrol needs setup');
expect(PATROL_WORKSPACE_SETUP_DESCRIPTION).toBe(
'Provider checks can pass while Patrol still needs a tool-call check. Open Provider & Models, then click Check Patrol model.',
'Patrol cannot check infrastructure until its selected model passes the Patrol tool check.',
);
expect(PATROL_WORKSPACE_QUEUE_TITLE).toBe('Open work');
expect(workspaceSource).not.toContain('Current Patrol findings');
@@ -39,10 +39,10 @@ export const PATROL_WORKSPACE_HISTORY_DESCRIPTION = 'Past checks and what Patrol
export const PATROL_WORKSPACE_RUN_RECORD_DESCRIPTION = 'What Patrol found during this run.';
export const PATROL_WORKSPACE_SETUP_TITLE = 'Check Patrol model';
export const PATROL_WORKSPACE_SETUP_TITLE = 'Patrol needs setup';
export const PATROL_WORKSPACE_SETUP_DESCRIPTION =
'Provider checks can pass while Patrol still needs a tool-call check. Open Provider & Models, then click Check Patrol model.';
'Patrol cannot check infrastructure until its selected model passes the Patrol tool check.';
export const PATROL_WORKSPACE_QUEUE_TITLE = 'Open work';
@@ -94,6 +94,7 @@ export const PlatformResourceDetailTableRow: Component<{
colSpan: number;
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
initialShowTrueNASDetails?: boolean;
onResourceActionSettled?: () => void | Promise<void>;
onClose?: () => void;
@@ -114,6 +115,7 @@ export const PlatformResourceDetailTableRow: Component<{
presentation="table-row"
resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext}
initialShowHostDetails={props.initialShowHostDetails}
initialShowTrueNASDetails={initialShowTrueNASDetails()}
onResourceActionSettled={props.onResourceActionSettled}
onClose={props.onClose}
@@ -104,7 +104,7 @@ export const ProxmoxBackupsTable: Component<{
// Search, the view toggle, and status facets stay ephemeral signals.
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = createSignal('');
const [view, setView] = createSignal<BackupView>('date');
const [selectedView, setSelectedView] = createSignal<BackupView | null>(null);
const [coverageFilter, setCoverageFilter] = createSignal<CoverageFilterValue>('all');
const [recoverableFilter, setRecoverableFilter] = createSignal<RecoverableFilterValue>('all');
@@ -300,6 +300,11 @@ export const ProxmoxBackupsTable: Component<{
uncovered: live.filter((row) => row.posture === 'uncovered').length,
};
});
const view = (): BackupView =>
selectedView() ?? (liveHealthSummary().attention > 0 ? 'coverage' : 'date');
const setView = (next: BackupView): void => {
setSelectedView(next);
};
// Recoverable artifact feed: PBS snapshots, PVE backup files, and guest snapshot rows.
const RECOVERABLE_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = [
@@ -327,6 +332,9 @@ export const ProxmoxBackupsTable: Component<{
},
),
);
const hasRecoverableActivityInRange = createMemo(() =>
recoverableTimeline().points.some((point) => point.total > 0),
);
const filteredRecoverableArtifacts = createMemo(() => {
const term = search().trim().toLowerCase();
@@ -551,19 +559,45 @@ export const ProxmoxBackupsTable: Component<{
/>
<Show when={view() === 'date' && recoveryModel().recoverableArtifacts.length > 0}>
<BackupActivityChart
title={
recoverableMetricMode() === 'volume' ? 'Backup volume per day' : 'Backups per day'
<Show
when={hasRecoverableActivityInRange()}
fallback={
<div class="flex flex-col gap-2 rounded-md border border-border-subtle bg-surface-alt/25 px-3 py-3 text-xs text-muted sm:flex-row sm:items-center sm:justify-between">
<span>No backup activity in the selected {chartRange()}-day window.</span>
<div class="flex items-center gap-2">
<button
type="button"
class="font-medium text-blue-600 hover:underline dark:text-blue-300"
onClick={() => setChartRange(90)}
>
Show 90 days
</button>
<span aria-hidden="true">·</span>
<button
type="button"
class="font-medium text-blue-600 hover:underline dark:text-blue-300"
onClick={() => setChartRange(365)}
>
Show 1 year
</button>
</div>
</div>
}
noun="backup"
segmentKinds={RECOVERABLE_SEGMENT_KINDS}
range={chartRange}
onRangeChange={setChartRange}
timeline={recoverableTimeline}
selectedDateKey={selectedDateKey}
onToggleDay={toggleDay}
metricToggle={{ mode: recoverableMetricMode, onChange: setRecoverableMetricMode }}
/>
>
<BackupActivityChart
title={
recoverableMetricMode() === 'volume' ? 'Backup volume per day' : 'Backups per day'
}
noun="backup"
segmentKinds={RECOVERABLE_SEGMENT_KINDS}
range={chartRange}
onRangeChange={setChartRange}
timeline={recoverableTimeline}
selectedDateKey={selectedDateKey}
onToggleDay={toggleDay}
metricToggle={{ mode: recoverableMetricMode, onChange: setRecoverableMetricMode }}
/>
</Show>
</Show>
<FilterBar
@@ -170,7 +170,7 @@ afterEach(() => {
});
describe('ProxmoxBackupsTable', () => {
it('defaults to the By date chronological feed with source/location/state columns', async () => {
it('defaults to coverage when protection needs attention and keeps the dated feed one click away', async () => {
mockBackupAPIs();
renderInRouter(() => (
@@ -181,10 +181,17 @@ describe('ProxmoxBackupsTable', () => {
/>
));
// Default view is the v5-parity backup feed: one row PER backup, so the
// guest with PBS + archive + snapshot artifacts appears on multiple rows,
// sourced and located, not collapsed to a single coverage-posture summary.
expect((await screen.findAllByText('pbs-docker')).length).toBeGreaterThan(1);
await screen.findAllByText('pbs-docker');
expect(screen.getByRole('button', { name: /coverage/i })).toHaveAttribute(
'aria-pressed',
'true',
);
await fireEvent.click(screen.getByRole('button', { name: /by date/i }));
// The chronological feed remains available for forensic review: one row
// per restore point, sourced and located.
expect(screen.getAllByText('pbs-docker').length).toBeGreaterThan(1);
expect(screen.getByRole('columnheader', { name: /location/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /source/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /type/i })).toBeInTheDocument();
@@ -273,6 +280,7 @@ describe('ProxmoxBackupsTable', () => {
));
await screen.findAllByText('pbs-docker');
await fireEvent.click(screen.getByRole('button', { name: /by date/i }));
const searchInput = screen.getByPlaceholderText(/search backups by workload/i);
await fireEvent.input(searchInput, { target: { value: 'no-such-guest' } });
@@ -839,8 +839,7 @@ const availabilityAddressFor = (machine: Resource): string => {
const address = asTrimmedString(availability?.address);
if (address) return address;
const identityWithIPAddresses = machine.identity as
| (Resource['identity'] & { ipAddresses?: string[] })
| undefined;
(Resource['identity'] & { ipAddresses?: string[] }) | undefined;
const firstIP = asTrimmedString(
identityWithIPAddresses?.ipAddresses?.[0] ?? machine.identity?.ips?.[0],
);
@@ -1179,6 +1178,10 @@ export const AgentsMachinesTable: Component<{
const columnVisibility = useColumnVisibility(
'pulse:standalone:machines:columns:v3',
AGENT_MACHINE_COLUMNS,
[],
undefined,
{},
['network', 'diskio', 'uptime', 'temp'],
);
const drawer = createPlatformResourceDetailState({ idPrefix: 'agents-machine-drawer' });
const [agentMetadataById, setAgentMetadataById] = createSignal<Record<string, AgentMetadata>>({});
@@ -1405,7 +1408,7 @@ export const AgentsMachinesTable: Component<{
>
<PlatformTableShell
title="Machines"
tableClass="min-w-full table-fixed text-xs md:min-w-[1210px]"
tableClass="min-w-full table-fixed text-xs md:min-w-[840px]"
header={
<>
<For each={visibleColumns()}>
@@ -1794,6 +1797,7 @@ export const AgentsMachinesTable: Component<{
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={detailColspan()}
initialShowHostDetails={false}
onClose={() => {
drawer.close(machine);
}}
@@ -8,10 +8,14 @@ import { RESOURCE_METADATA_CHANGED_EVENT } from '@/utils/resourceMetadataEvents'
import { AgentsMachinesTable } from '../AgentsMachinesTable';
vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
ResourceDetailDrawer: (props: { initialShowAccessContext?: boolean }) => (
ResourceDetailDrawer: (props: {
initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
}) => (
<div
data-testid="resource-detail-drawer"
data-initial-show-access-context={String(props.initialShowAccessContext ?? false)}
data-initial-show-host-details={String(props.initialShowHostDetails ?? true)}
/>
),
}));
@@ -200,6 +204,12 @@ describe('AgentsMachinesTable', () => {
/>
));
expect(screen.queryByRole('button', { name: 'Sort by Net I/O' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sort by Disk I/O' })).not.toBeInTheDocument();
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await fireEvent.click(screen.getByLabelText('Net I/O'));
await fireEvent.click(screen.getByLabelText('Disk I/O'));
expect(screen.getByRole('button', { name: 'Sort by Net I/O' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sort by Disk I/O' })).toBeInTheDocument();
expect(screen.getByText('1.00 KB/s')).toBeInTheDocument();
@@ -207,7 +217,6 @@ describe('AgentsMachinesTable', () => {
expect(screen.queryByRole('button', { name: 'Sort by IP' })).not.toBeInTheDocument();
expect(screen.getByText('192.168.0.10')).toBeInTheDocument();
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await fireEvent.click(screen.getByLabelText('IP'));
await fireEvent.click(screen.getByLabelText('RAID'));
@@ -512,7 +521,10 @@ describe('AgentsMachinesTable', () => {
expect(
screen.getByRole('button', { name: 'Collapse details for Expandable Machine' }),
).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('resource-detail-drawer')).toBeInTheDocument();
expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
'data-initial-show-host-details',
'false',
);
});
it('removes agent machines through row actions with confirmation', async () => {
@@ -644,6 +656,8 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await fireEvent.click(screen.getByLabelText('Net I/O'));
const trigger = container.querySelector('[data-agent-machine-network-trigger="true"]');
expect(trigger).not.toBeNull();
if (!trigger) return;
@@ -736,6 +750,8 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await fireEvent.click(screen.getByLabelText('Disk I/O'));
const trigger = container.querySelector('[data-agent-machine-diskio-trigger="true"]');
expect(trigger).not.toBeNull();
if (!trigger) return;
@@ -938,6 +954,8 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await fireEvent.click(screen.getByLabelText('Temp'));
const trigger = container.querySelector('[data-agent-machine-temperature-trigger="true"]');
expect(trigger).not.toBeNull();
if (!trigger) return;
@@ -979,6 +997,8 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await fireEvent.click(screen.getByLabelText('Temp'));
const pressure = await screen.findByText('Nominal');
expect(pressure).toBeInTheDocument();
@@ -54,10 +54,38 @@ export const AGENT_MACHINE_COLUMNS: AgentMachineColumn[] = [
{ id: 'cpu', label: 'CPU', kind: 'metric-bar', sortKey: 'cpu' },
{ id: 'memory', label: 'Memory', kind: 'metric-bar', sortKey: 'memory' },
{ id: 'disk', label: 'Disk', kind: 'metric-bar', sortKey: 'disk' },
{ id: 'network', label: 'Net I/O', kind: 'numeric-value', sortKey: 'network', toggleable: true },
{ id: 'diskio', label: 'Disk I/O', kind: 'numeric-value', sortKey: 'diskio', toggleable: true },
{ id: 'uptime', label: 'Uptime', kind: 'numeric-value', sortKey: 'uptime', toggleable: true },
{ id: 'temp', label: 'Temp', kind: 'numeric-value', sortKey: 'temp', toggleable: true },
{
id: 'network',
label: 'Net I/O',
kind: 'numeric-value',
sortKey: 'network',
toggleable: true,
defaultHidden: true,
},
{
id: 'diskio',
label: 'Disk I/O',
kind: 'numeric-value',
sortKey: 'diskio',
toggleable: true,
defaultHidden: true,
},
{
id: 'uptime',
label: 'Uptime',
kind: 'numeric-value',
sortKey: 'uptime',
toggleable: true,
defaultHidden: true,
},
{
id: 'temp',
label: 'Temp',
kind: 'numeric-value',
sortKey: 'temp',
toggleable: true,
defaultHidden: true,
},
{
id: 'lastSeen',
label: 'Last seen',
+181 -136
View File
@@ -6,6 +6,7 @@ import { useLocation, useNavigate } from '@solidjs/router';
import { logger } from '@/utils/logger';
import { t } from '@/i18n';
import { Card } from '@/components/shared/Card';
import { Button } from '@/components/shared/Button';
import { PageHeader } from '@/components/shared/PageHeader';
import { notificationStore } from '@/stores/notifications';
@@ -34,6 +35,7 @@ import LayoutDashboard from 'lucide-solid/icons/layout-dashboard';
import History from 'lucide-solid/icons/history';
import Gauge from 'lucide-solid/icons/gauge';
import Send from 'lucide-solid/icons/send';
import BellOff from 'lucide-solid/icons/bell-off';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import { AlertsConfigurationSurface } from '@/features/alerts/AlertsConfigurationSurface';
import { OverviewTab } from '@/features/alerts/OverviewTab';
@@ -252,162 +254,205 @@ export function Alerts() {
}
/>
<Card padding="none" class="relative lg:flex overflow-hidden">
<div
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-border lg:border-b-0 lg:border-r lg:align-top flex-shrink-0 transition-all duration-200`}
aria-label={t('alerts.nav.ariaLabel')}
aria-expanded={!sidebarCollapsed()}
>
<Show
when={!alertsConfigurationLocked()}
fallback={
<Card padding="none" class="overflow-hidden">
<div class="grid gap-5 p-5 sm:p-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div class="flex min-w-0 gap-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300">
<BellOff class="h-5 w-5" aria-hidden="true" />
</div>
<div class="min-w-0">
<h2 class="text-base font-semibold text-base-content">Alerts are paused</h2>
<p class="mt-1 text-sm leading-6 text-muted">
Pulse is not evaluating thresholds or sending new alert notifications. Existing
alert history and configuration are preserved.
</p>
<p class="mt-2 text-xs text-muted">
Enable alerts to resume monitoring and unlock thresholds, notifications, and
maintenance schedules.
</p>
</div>
</div>
<Button
type="button"
variant="primary"
size="md"
class="w-full justify-center sm:w-auto"
disabled={alertsActivation.isLoading() || isSwitchingActivation()}
onClick={() => void handleActivateAlerts()}
>
{alertsActivation.isLoading() || isSwitchingActivation()
? 'Enabling alerts…'
: 'Enable alerts'}
</Button>
</div>
</Card>
}
>
<Card padding="none" class="relative lg:flex overflow-hidden">
<div
class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-border lg:border-b-0 lg:border-r lg:align-top flex-shrink-0 transition-all duration-200`}
aria-label={t('alerts.nav.ariaLabel')}
aria-expanded={!sidebarCollapsed()}
>
<Show when={!sidebarCollapsed()}>
<div class="flex items-center justify-between pb-2 border-b border-border">
<h2 class="text-sm font-semibold text-base-content">{t('alerts.nav.title')}</h2>
<div
class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
>
<Show when={!sidebarCollapsed()}>
<div class="flex items-center justify-between pb-2 border-b border-border">
<h2 class="text-sm font-semibold text-base-content">{t('alerts.nav.title')}</h2>
<button
type="button"
onClick={() => setSidebarCollapsed(true)}
class="p-1 rounded-md hover:bg-surface-hover transition-colors"
aria-label={t('alerts.nav.collapseSidebar')}
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
/>
</svg>
</button>
</div>
</Show>
<Show when={sidebarCollapsed()}>
<button
type="button"
onClick={() => setSidebarCollapsed(true)}
class="p-1 rounded-md hover:bg-surface-hover transition-colors"
aria-label={t('alerts.nav.collapseSidebar')}
onClick={() => setSidebarCollapsed(false)}
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
aria-label={t('alerts.nav.expandSidebar')}
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
class="w-5 h-5 mx-auto"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
</svg>
</button>
</div>
</Show>
<Show when={sidebarCollapsed()}>
<button
type="button"
onClick={() => setSidebarCollapsed(false)}
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
aria-label={t('alerts.nav.expandSidebar')}
>
<svg class="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
</svg>
</button>
</Show>
<div id="alerts-sidebar-menu" class="space-y-5">
<For each={tabGroups()}>
{(group) => (
<div class="space-y-2">
<Show when={!sidebarCollapsed()}>
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
{group.label}
</p>
</Show>
<div class="space-y-1.5">
<For each={group.items}>
{(item) => (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsSidebarTabClass({
isActive: activeTab() === item.id,
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
})}
onClick={() => handleTabChange(item.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
label: item.label,
})}
>
{item.icon}
<Show when={!sidebarCollapsed()}>
<span class="truncate">{item.label}</span>
</Show>
</button>
)}
</For>
</Show>
<div id="alerts-sidebar-menu" class="space-y-5">
<For each={tabGroups()}>
{(group) => (
<div class="space-y-2">
<Show when={!sidebarCollapsed()}>
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
{group.label}
</p>
</Show>
<div class="space-y-1.5">
<For each={group.items}>
{(item) => (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsSidebarTabClass({
isActive: activeTab() === item.id,
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
})}
onClick={() => handleTabChange(item.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
collapsed: sidebarCollapsed(),
label: item.label,
})}
>
{item.icon}
<Show when={!sidebarCollapsed()}>
<span class="truncate">{item.label}</span>
</Show>
</button>
)}
</For>
</div>
</div>
</div>
)}
</For>
)}
</For>
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-hidden">
<Show when={flatTabs().length > 0}>
<div class="lg:hidden border-b border-border">
<div class="p-1">
<div class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide">
<For each={flatTabs()}>
{(tab) => (
<button
type="button"
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsMobileTabClass({
isActive: activeTab() === tab.id,
isDisabled: alertsConfigurationLocked(),
})}
onClick={() => handleTabChange(tab.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
label: tab.label,
})}
>
<span class="w-full text-center truncate block">{tab.label}</span>
</button>
)}
</For>
<div class="flex-1 overflow-hidden">
<Show when={flatTabs().length > 0}>
<div class="lg:hidden border-b border-border">
<div class="p-1">
<div class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide">
<For each={flatTabs()}>
{(tab) => (
<button
type="button"
aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()}
class={getAlertsMobileTabClass({
isActive: activeTab() === tab.id,
isDisabled: alertsConfigurationLocked(),
})}
onClick={() => handleTabChange(tab.id)}
title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(),
label: tab.label,
})}
>
<span class="w-full text-center truncate block">{tab.label}</span>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
<div class="p-2 sm:p-6">
<Show when={activeTab() === 'overview'}>
<OverviewTab
overrides={overviewOverrides()}
activeAlerts={activeAlerts}
updateAlert={updateAlert}
showQuickTip={showQuickTip}
dismissQuickTip={dismissQuickTip}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
alertsDisabled={alertsConfigurationLocked}
/>
</Show>
<Show when={!readOnlySession()}>
<AlertsConfigurationSurface
activeTab={activeTab}
allResources={allResources}
byType={byType}
children={children}
activeAlerts={activeAlerts}
removeAlerts={removeAlerts}
setOverviewOverrides={setOverviewOverrides}
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
alertsActivationState={alertsActivation.activationState}
alertsActivationConfig={alertsActivation.config}
/>
</Show>
<Show when={activeTab() === 'history'}>
<HistoryTab getResource={getResource} allResources={allResources} />
</Show>
</div>
</Show>
<div class="p-2 sm:p-6">
<Show when={activeTab() === 'overview'}>
<OverviewTab
overrides={overviewOverrides()}
activeAlerts={activeAlerts}
updateAlert={updateAlert}
showQuickTip={showQuickTip}
dismissQuickTip={dismissQuickTip}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
alertsDisabled={alertsConfigurationLocked}
/>
</Show>
<Show when={!readOnlySession()}>
<AlertsConfigurationSurface
activeTab={activeTab}
allResources={allResources}
byType={byType}
children={children}
activeAlerts={activeAlerts}
removeAlerts={removeAlerts}
setOverviewOverrides={setOverviewOverrides}
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
alertsActivationState={alertsActivation.activationState}
alertsActivationConfig={alertsActivation.config}
/>
</Show>
<Show when={activeTab() === 'history'}>
<HistoryTab getResource={getResource} allResources={allResources} />
</Show>
</div>
</div>
</Card>
</Card>
</Show>
</div>
);
}
@@ -753,16 +753,14 @@ describe('AIIntelligence entitlement gating', () => {
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Check Patrol model' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Patrol needs setup' })).toBeInTheDocument();
});
expect(
screen.getByText(
'Provider checks can pass while Patrol still needs a tool-call check. Open Provider & Models, then click Check Patrol model.',
'Patrol cannot check infrastructure until its selected model passes the Patrol tool check.',
),
).toBeInTheDocument();
expect(
screen.getByText(/Patrol check:\s*Selected model cannot run Patrol tools\./),
).toBeInTheDocument();
expect(screen.getByText('Selected model cannot run Patrol tools.')).toBeInTheDocument();
expect(
screen.queryByText(
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
@@ -780,13 +778,10 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByText(/Trigger status:/)).not.toBeInTheDocument();
expect(screen.queryByText(/local development safety guard/)).not.toBeInTheDocument();
const providerSettingsLinks = screen.getAllByRole('link', {
name: 'Open Provider & Models',
name: 'Check Patrol model',
});
expect(providerSettingsLinks.length).toBeGreaterThan(0);
expect(providerSettingsLinks[0]).toHaveAttribute(
'href',
'/settings/pulse-intelligence/provider',
);
expect(providerSettingsLinks[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol');
expect(screen.queryByRole('button', { name: /Run Patrol/i })).not.toBeInTheDocument();
expect(triggerPatrolRunMock).not.toHaveBeenCalled();
});
@@ -1849,7 +1844,7 @@ describe('AIIntelligence entitlement gating', () => {
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Check Patrol model' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Patrol needs setup' })).toBeInTheDocument();
});
expect(
@@ -1857,21 +1852,19 @@ describe('AIIntelligence entitlement gating', () => {
).not.toBeInTheDocument();
expect(
screen.getByText(
'Provider checks can pass while Patrol still needs a tool-call check. Open Provider & Models, then click Check Patrol model.',
'Patrol cannot check infrastructure until its selected model passes the Patrol tool check.',
),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Patrol model needs attention' }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Patrol cannot run yet' })).toBeInTheDocument();
expect(
screen.queryByText('Fix the provider connection, then Patrol can check infrastructure.'),
).not.toBeInTheDocument();
expect(screen.getByText('Patrol check: Provider billing or quota issue')).toBeInTheDocument();
expect(screen.getByText('Provider billing or quota issue')).toBeInTheDocument();
expect(screen.queryByText('Patrol setup issue')).not.toBeInTheDocument();
expect(screen.queryByText('Patrol readiness issue')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Check Patrol model' })).toHaveAttribute(
'href',
'/settings/pulse-intelligence/provider',
'/settings/pulse-intelligence/patrol',
);
expect(screen.queryByText('Runtime issue')).not.toBeInTheDocument();
expect(screen.queryByText(/regressed \d+×/)).not.toBeInTheDocument();
@@ -1957,8 +1950,8 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
const providerActions = screen.getAllByRole('link', { name: /Check Patrol model/i });
expect(providerActions.length).toBeGreaterThan(0);
expect(providerActions[0]).toHaveAttribute('href', '/settings/pulse-intelligence/provider');
expect(screen.queryByRole('link', { name: 'Open Provider & Models' })).not.toBeInTheDocument();
expect(providerActions[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol');
expect(screen.queryByRole('link', { name: 'Open Patrol settings' })).not.toBeInTheDocument();
});
it('does not repeat stale coverage caveats after a successful full patrol verified resources', async () => {
@@ -2357,9 +2350,9 @@ describe('AIIntelligence entitlement gating', () => {
/Checked 72 resources in 58m\. Patrol ended with a runtime issue: Selected model does not support Patrol tools/i,
),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Check Patrol model' })).toHaveAttribute(
'href',
'/settings/pulse-intelligence/provider',
'/settings/pulse-intelligence/patrol',
);
expect(findingsPanelState.latestProps).toMatchObject({
filterOverride: 'all',
@@ -169,4 +169,20 @@ describe('Alerts read-only presentation', () => {
expect(screen.getAllByText('Thresholds').length).toBeGreaterThan(0);
expect(navigateSpy).not.toHaveBeenCalledWith('/alerts/overview', { replace: true });
});
it('replaces the disabled navigation shell with one clear activation task', async () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(false);
activationStateMock.mockReturnValue('pending_review');
render(() => <Alerts />);
expect(await screen.findByRole('heading', { name: 'Alerts are paused' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Enable alerts' })).toBeInTheDocument();
expect(
screen.getByText(/not evaluating thresholds or sending new alert notifications/i),
).toBeInTheDocument();
expect(screen.queryByTestId('overview-tab')).not.toBeInTheDocument();
expect(screen.queryByTestId('alerts-config-surface')).not.toBeInTheDocument();
expect(screen.queryByText('Thresholds')).not.toBeInTheDocument();
});
});
@@ -106,8 +106,8 @@ describe('discoveryPresentation', () => {
label: 'Settings → API Access',
});
expect(getDiscoveryServiceContextSettingsTarget()).toEqual({
href: '/settings/pulse-intelligence/discovery',
label: 'Open Service Context settings',
href: '/settings/pulse-intelligence/assistant',
label: 'Open Assistant settings',
});
expect(getDiscoveryNoConnectedAgentMessage(false)).toBe(
'Commands not enabled. Enable Pulse commands from Settings → Infrastructure for this agent.',
@@ -88,8 +88,8 @@ describe('patrolRunPresentation', () => {
error_detail: 'Provider rejected tool_choice',
}),
).toEqual({
label: 'Open Provider & Models',
href: '/settings/pulse-intelligence/provider',
label: 'Check Patrol model',
href: '/settings/pulse-intelligence/patrol',
});
expect(
getPatrolRunPrimaryActionPresentation({
@@ -126,8 +126,8 @@ describe('patrolRunPresentation', () => {
}),
).toEqual({
action: {
label: 'Open Provider & Models',
href: '/settings/pulse-intelligence/provider',
label: 'Check Patrol model',
href: '/settings/pulse-intelligence/patrol',
},
summary: 'Checked 72 resources in 58m.',
outcome:
@@ -7,8 +7,8 @@ import {
describe('patrolRuntimeActions', () => {
it('centralizes the Patrol provider settings action', () => {
expect(PATROL_PROVIDER_SETTINGS_ACTION).toEqual({
label: 'Open Provider & Models',
href: '/settings/pulse-intelligence/provider',
label: 'Check Patrol model',
href: '/settings/pulse-intelligence/patrol',
});
const action = getPatrolProviderSettingsAction();
@@ -372,8 +372,8 @@ describe('getPatrolSummaryPresentation', () => {
] as never,
}),
).toEqual({
label: 'Open Provider & Models',
href: '/settings/pulse-intelligence/provider',
label: 'Check Patrol model',
href: '/settings/pulse-intelligence/patrol',
});
});
@@ -745,8 +745,7 @@ describe('getPatrolSummaryPresentation', () => {
}),
).toEqual({
title: 'Needs full check',
description:
'Recent targeted checks covered 1 resource. Run Patrol to check everything.',
description: 'Recent targeted checks covered 1 resource. Run Patrol to check everything.',
compactLabel: 'Partial check',
tone: 'warning',
});
@@ -789,8 +788,7 @@ describe('getPatrolSummaryPresentation', () => {
}),
).toEqual({
title: 'Needs full check',
description:
'Recent follow-up checks covered 1 resource. Run Patrol to check everything.',
description: 'Recent follow-up checks covered 1 resource. Run Patrol to check everything.',
compactLabel: 'Partial check',
tone: 'warning',
});
@@ -7,7 +7,7 @@ import {
getInfrastructureSettingsLocationLabel,
getInfrastructureSettingsTarget,
} from '@/utils/infrastructureSettingsPresentation';
import { SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH } from '@/routing/resourceLinks';
import { SETTINGS_PULSE_INTELLIGENCE_ASSISTANT_PATH } from '@/routing/resourceLinks';
export interface DiscoveryIdentifiedSummary {
serviceName: string;
@@ -322,8 +322,8 @@ export function getDiscoveryApiAccessSettingsTarget() {
export function getDiscoveryServiceContextSettingsTarget() {
return {
href: SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH,
label: 'Open Service Context settings',
href: SETTINGS_PULSE_INTELLIGENCE_ASSISTANT_PATH,
label: 'Open Assistant settings',
} as const;
}
@@ -1,4 +1,4 @@
import { SETTINGS_PROVIDER_MODELS_PATH } from '@/components/Settings/settingsNavigationModel';
import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
export interface PatrolRuntimeActionPresentation {
label: string;
@@ -6,8 +6,8 @@ export interface PatrolRuntimeActionPresentation {
}
export const PATROL_PROVIDER_SETTINGS_ACTION: PatrolRuntimeActionPresentation = {
label: 'Open Provider & Models',
href: SETTINGS_PROVIDER_MODELS_PATH,
label: 'Check Patrol model',
href: settingsTabPath('system-ai-patrol'),
};
export const getPatrolProviderSettingsAction = (): PatrolRuntimeActionPresentation => ({