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"> <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" /> <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"> <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"> <p class="text-amber-700 dark:text-amber-300">
Enable service context to inspect what is running on this resource. Enable service context to inspect what is running on this resource.
</p> </p>
@@ -88,7 +88,7 @@ describe('DiscoveryTab', () => {
<DiscoveryTab resourceType="agent" agentId="agent-1" resourceId="agent-1" hostname="pve1" /> <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.getDiscovery).not.toHaveBeenCalled();
expect(discoveryApi.getDiscoveryInfo).not.toHaveBeenCalled(); expect(discoveryApi.getDiscoveryInfo).not.toHaveBeenCalled();
expect(discoveryApi.getConnectedAgents).not.toHaveBeenCalled(); expect(discoveryApi.getConnectedAgents).not.toHaveBeenCalled();
@@ -15,7 +15,7 @@ export type DiscoveryReadinessStatus =
| 'ready'; | 'ready';
export interface DiscoveryReadinessInputs { export interface DiscoveryReadinessInputs {
/** The service context toggle (Settings -> Pulse Intelligence -> Service Context). */ /** The service context toggle (Settings -> Pulse Intelligence -> Assistant). */
discoveryEnabled: boolean; discoveryEnabled: boolean;
/** Whether at least one AI provider has credentials configured. */ /** Whether at least one AI provider has credentials configured. */
aiProviderConfigured: boolean; aiProviderConfigured: boolean;
@@ -198,9 +198,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
const handleTriggerDiscovery = async (force = false) => { const handleTriggerDiscovery = async (force = false) => {
if (!discoveryFeatureEnabled()) { if (!discoveryFeatureEnabled()) {
setScanError( setScanError('Service context is disabled in Settings -> Pulse Intelligence -> Assistant.');
'Service context is disabled in Settings -> Pulse Intelligence -> Service Context.',
);
return; return;
} }
@@ -38,6 +38,7 @@ interface ResourceDetailDrawerProps {
presentation?: ResourceDetailDrawerPresentation; presentation?: ResourceDetailDrawerPresentation;
resolveResourceLabel?: (resourceId: string) => string | null | undefined; resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean; initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
initialShowTrueNASDetails?: boolean; initialShowTrueNASDetails?: boolean;
onResourceActionSettled?: () => void | Promise<void>; onResourceActionSettled?: () => void | Promise<void>;
} }
@@ -69,6 +70,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
presentation: presentation(), presentation: presentation(),
resolveResourceLabel: props.resolveResourceLabel, resolveResourceLabel: props.resolveResourceLabel,
initialShowAccessContext: props.initialShowAccessContext, initialShowAccessContext: props.initialShowAccessContext,
initialShowHostDetails: props.initialShowHostDetails,
initialShowTrueNASDetails: props.initialShowTrueNASDetails, initialShowTrueNASDetails: props.initialShowTrueNASDetails,
}); });
const headingId = () => `resource-detail-drawer-heading-${props.resource.id}`; const headingId = () => `resource-detail-drawer-heading-${props.resource.id}`;
@@ -354,6 +356,7 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
presentation={props.presentation} presentation={props.presentation}
resolveResourceLabel={props.resolveResourceLabel} resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext} initialShowAccessContext={props.initialShowAccessContext}
initialShowHostDetails={props.initialShowHostDetails}
initialShowTrueNASDetails={props.initialShowTrueNASDetails} initialShowTrueNASDetails={props.initialShowTrueNASDetails}
onResourceActionSettled={props.onResourceActionSettled} onResourceActionSettled={props.onResourceActionSettled}
/> />
@@ -358,14 +358,14 @@ describe('toDiscoveryConfig', () => {
}); });
describe('resource drawer discovery promotion', () => { describe('resource drawer discovery promotion', () => {
it('points disabled discovery readiness at Pulse Intelligence service context settings', () => { it('points disabled discovery readiness at Assistant-owned service identification settings', () => {
expect(discoveryTabSource).toContain('Service Context Disabled'); expect(discoveryTabSource).toContain('Service identification is off');
expect(discoveryTabSource).toContain('getDiscoveryServiceContextSettingsTarget'); expect(discoveryTabSource).toContain('getDiscoveryServiceContextSettingsTarget');
expect(discoveryTabSource).toContain('serviceContextSettingsTarget.label'); expect(discoveryTabSource).toContain('serviceContextSettingsTarget.label');
expect(discoveryTabStateSource).toContain( 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', () => { 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'; import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
type DrawerTab = type DrawerTab =
| 'overview' 'overview' | 'history' | 'discovery' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
| 'history'
| 'discovery'
| 'mail'
| 'namespaces'
| 'deployments'
| 'swarm'
| 'debug';
export interface UseResourceDetailDrawerStateOptions { export interface UseResourceDetailDrawerStateOptions {
resource: Resource; resource: Resource;
resolveResourceLabel?: (resourceId: string) => string | null | undefined; resolveResourceLabel?: (resourceId: string) => string | null | undefined;
presentation?: ResourceDetailDrawerPresentation; presentation?: ResourceDetailDrawerPresentation;
initialShowAccessContext?: boolean; initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
initialShowTrueNASDetails?: boolean; initialShowTrueNASDetails?: boolean;
} }
@@ -52,7 +46,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
const [showInvestigationContext, setShowInvestigationContext] = createSignal(false); const [showInvestigationContext, setShowInvestigationContext] = createSignal(false);
const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false); const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false);
const [showHostDetails, setShowHostDetails] = createSignal( const [showHostDetails, setShowHostDetails] = createSignal(
options.presentation === 'table-row' && isPulseAgentPlatformResource(resource), options.initialShowHostDetails ??
(options.presentation === 'table-row' && isPulseAgentPlatformResource(resource)),
); );
const [showServiceDetails, setShowServiceDetails] = createSignal(false); const [showServiceDetails, setShowServiceDetails] = createSignal(false);
const [showVMwareDetails, setShowVMwareDetails] = createSignal(false); const [showVMwareDetails, setShowVMwareDetails] = createSignal(false);
@@ -267,6 +267,22 @@ const AssistantSettingsContent: Component<{ state: ReturnType<typeof useAISettin
<AIModelOverrideField state={props.state} kind="assistant" /> <AIModelOverrideField state={props.state} kind="assistant" />
<AIAssistantCommandAccessSection state={props.state} /> <AIAssistantCommandAccessSection state={props.state} />
<AIChatMaintenanceSection 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> </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 { Dynamic } from 'solid-js/web';
import { useNavigate, useLocation } from '@solidjs/router'; import { useNavigate, useLocation } from '@solidjs/router';
import { useWebSocket } from '@/contexts/appRuntime'; import { useWebSocket } from '@/contexts/appRuntime';
@@ -147,6 +155,11 @@ const SettingsWorkspace: Component<SettingsProps> = (props) => {
return settingsPanelRegistry()[currentTab]; return settingsPanelRegistry()[currentTab];
}); });
createEffect(() => {
activeTab();
queueMicrotask(() => window.scrollTo({ top: 0, behavior: 'auto' }));
});
onMount(() => { onMount(() => {
void loadRuntimeCapabilities(); void loadRuntimeCapabilities();
}); });
@@ -34,7 +34,8 @@ describe('settings localization catalog', () => {
expect(groups[0]?.label).toBe('Infrastruktur'); expect(groups[0]?.label).toBe('Infrastruktur');
expect(groups[0]?.items[0]?.label).toBe('Infrastruktur'); expect(groups[0]?.items[0]?.label).toBe('Infrastruktur');
expect(getSettingsNavItem('system-updates', 'de')?.label).toBe('Updates'); 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'); 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', 'es')?.label).toBe('Proveedores y modelos');
expect(getSettingsNavItem('system-ai-patrol', 'es')?.label).toBe('Patrol'); expect(getSettingsNavItem('system-ai-patrol', 'es')?.label).toBe('Patrol');
expect(getSettingsNavItem('system-ai-assistant', 'es')?.label).toBe('Assistant'); 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'); 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< for (const [tab, path] of Object.entries(dispatchableCanonicalTabPaths) as Array<
[SettingsTab, string] [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', () => { it('round-trips settingsTabPath through deriveTabFromPath', () => {
for (const tab of Object.keys(dispatchableCanonicalTabPaths) as SettingsTab[]) { 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[]) { for (const tab of Object.keys(dispatchableCanonicalTabPaths) as SettingsTab[]) {
const path = settingsTabPath(tab); const path = settingsTabPath(tab);
const derived = deriveTabFromPath(path); 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=system-ai', 'system-ai'],
['?tab=patrol', 'system-ai-patrol'], ['?tab=patrol', 'system-ai-patrol'],
['?tab=assistant', 'system-ai-assistant'], ['?tab=assistant', 'system-ai-assistant'],
['?tab=discovery', 'system-ai-discovery'], ['?tab=discovery', 'system-ai-assistant'],
['?ai_oauth_error=unsupported', 'system-ai'], ['?ai_oauth_error=unsupported', 'system-ai'],
['?ai_oauth_success=true', 'system-ai'], ['?ai_oauth_success=true', 'system-ai'],
['?tab=system-relay', 'system-relay'], ['?tab=system-relay', 'system-relay'],
@@ -81,12 +81,6 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: Terminal, icon: Terminal,
iconProps: { strokeWidth: 2 }, 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'); return settingsTabPath('system-ai');
} }
if (normalizedPath === PULSE_INTELLIGENCE_DISCOVERY_PREFIX) {
return settingsTabPath('system-ai-assistant');
}
if (normalizedPath === SUPPORT_PREFIX) { if (normalizedPath === SUPPORT_PREFIX) {
return SUPPORT_DIAGNOSTICS_PREFIX; return SUPPORT_DIAGNOSTICS_PREFIX;
} }
@@ -327,7 +330,7 @@ export function deriveTabFromQuery(search: string): SettingsTab | null {
return 'system-ai-assistant'; return 'system-ai-assistant';
case 'system-ai-discovery': case 'system-ai-discovery':
case 'discovery': case 'discovery':
return 'system-ai-discovery'; return 'system-ai-assistant';
case 'system-relay': case 'system-relay':
return 'system-relay'; return 'system-relay';
case 'system-billing': case 'system-billing':
@@ -101,11 +101,11 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
<A <A
href={providerSetupAction.href} href={providerSetupAction.href}
aria-label={`Check Patrol model: ${state.triggerPatrolDisabledReason() || 'Patrol model needs attention'}`} 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} class={className}
> >
<SettingsIcon class="w-4 h-4" /> <SettingsIcon class="w-4 h-4" />
Check model Fix setup
</A> </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', '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 <Show when={!runBlockedByProviderSetup()}>
href={settingsTabPath('system-ai-patrol')} <A
aria-label="Open Patrol settings" href={settingsTabPath('system-ai-patrol')}
title="Open Patrol settings" aria-label="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" 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> <SettingsIcon class="w-4 h-4" />
</A> <span class="sr-only sm:not-sr-only">Settings</span>
</A>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
@@ -1,6 +1,8 @@
import { createMemo, For, Show } from 'solid-js'; import { createMemo, For, Show } from 'solid-js';
import HistoryIcon from 'lucide-solid/icons/history'; import HistoryIcon from 'lucide-solid/icons/history';
import SettingsIcon from 'lucide-solid/icons/settings'; 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 { FindingsPanel } from '@/components/AI/FindingsPanel';
import { ApprovalBanner, RunHistoryPanel } from '@/components/patrol'; import { ApprovalBanner, RunHistoryPanel } from '@/components/patrol';
import { import {
@@ -136,8 +138,7 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
}), }),
); );
const hasVisibleWorkGroups = () => const hasVisibleWorkGroups = () =>
queueIssueCount() > 0 || queueIssueCount() > 0 || workGroupSummaries().some((group) => group.id !== 'stale-protection');
workGroupSummaries().some((group) => group.id !== 'stale-protection');
const shouldShowWorkGroups = () => const shouldShowWorkGroups = () =>
!isHistoryOpen() && !isSetupOnly() && !state.selectedRun() && hasVisibleWorkGroups(); !isHistoryOpen() && !isSetupOnly() && !state.selectedRun() && hasVisibleWorkGroups();
@@ -178,7 +179,7 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
</div> </div>
<p class="mt-1 text-xs text-muted">{workspaceDescription()}</p> <p class="mt-1 text-xs text-muted">{workspaceDescription()}</p>
</div> </div>
<Show when={!isSetupOnly()}> <Show when={!isSetupOnly() || (state.patrolRunHistory.value()?.length ?? 0) > 0}>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Button <Button
type="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="overflow-hidden rounded-md border border-amber-300 bg-amber-50/70 dark:border-amber-800 dark:bg-amber-950/20">
<div class="flex flex-wrap items-start justify-between gap-3"> <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 max-w-2xl"> <div class="min-w-0">
<h3 class="text-sm font-semibold text-base-content"> <div class="flex items-center gap-2 text-amber-800 dark:text-amber-200">
Patrol model needs attention <ShieldAlertIcon class="h-5 w-5 shrink-0" aria-hidden="true" />
</h3> <h3 class="text-sm font-semibold">Patrol cannot run yet</h3>
<p class="mt-2 text-xs text-muted">Patrol check: {setupReason()}</p> </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> </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>
</div> </div>
</Show> </Show>
@@ -65,14 +65,14 @@ describe('PatrolIntelligenceWorkspace trust strip', () => {
'Open work Patrol can ' + 'Open work Patrol can ' +
'watch, investigate, ask you to approve, or record under your Patrol mode.'; 'watch, investigate, ask you to approve, or record under your Patrol mode.';
expect(workspaceSource).not.toContain(removedAllModeCopy); expect(workspaceSource).not.toContain(removedAllModeCopy);
expect(workspaceSource).toContain('Patrol model needs attention'); expect(workspaceSource).toContain('Patrol cannot run yet');
expect(workspaceSource).toContain('Patrol check:'); expect(workspaceSource).toContain('Once ready');
expect(workspaceSource).toContain('getPatrolProviderSettingsAction'); 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(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( 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(PATROL_WORKSPACE_QUEUE_TITLE).toBe('Open work');
expect(workspaceSource).not.toContain('Current Patrol findings'); 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_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 = 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'; export const PATROL_WORKSPACE_QUEUE_TITLE = 'Open work';
@@ -94,6 +94,7 @@ export const PlatformResourceDetailTableRow: Component<{
colSpan: number; colSpan: number;
resolveResourceLabel?: (resourceId: string) => string | null | undefined; resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean; initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
initialShowTrueNASDetails?: boolean; initialShowTrueNASDetails?: boolean;
onResourceActionSettled?: () => void | Promise<void>; onResourceActionSettled?: () => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
@@ -114,6 +115,7 @@ export const PlatformResourceDetailTableRow: Component<{
presentation="table-row" presentation="table-row"
resolveResourceLabel={props.resolveResourceLabel} resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext} initialShowAccessContext={props.initialShowAccessContext}
initialShowHostDetails={props.initialShowHostDetails}
initialShowTrueNASDetails={initialShowTrueNASDetails()} initialShowTrueNASDetails={initialShowTrueNASDetails()}
onResourceActionSettled={props.onResourceActionSettled} onResourceActionSettled={props.onResourceActionSettled}
onClose={props.onClose} onClose={props.onClose}
@@ -104,7 +104,7 @@ export const ProxmoxBackupsTable: Component<{
// Search, the view toggle, and status facets stay ephemeral signals. // Search, the view toggle, and status facets stay ephemeral signals.
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = createSignal(''); const [search, setSearch] = createSignal('');
const [view, setView] = createSignal<BackupView>('date'); const [selectedView, setSelectedView] = createSignal<BackupView | null>(null);
const [coverageFilter, setCoverageFilter] = createSignal<CoverageFilterValue>('all'); const [coverageFilter, setCoverageFilter] = createSignal<CoverageFilterValue>('all');
const [recoverableFilter, setRecoverableFilter] = createSignal<RecoverableFilterValue>('all'); const [recoverableFilter, setRecoverableFilter] = createSignal<RecoverableFilterValue>('all');
@@ -300,6 +300,11 @@ export const ProxmoxBackupsTable: Component<{
uncovered: live.filter((row) => row.posture === 'uncovered').length, 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. // Recoverable artifact feed: PBS snapshots, PVE backup files, and guest snapshot rows.
const RECOVERABLE_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = [ 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 filteredRecoverableArtifacts = createMemo(() => {
const term = search().trim().toLowerCase(); const term = search().trim().toLowerCase();
@@ -551,19 +559,45 @@ export const ProxmoxBackupsTable: Component<{
/> />
<Show when={view() === 'date' && recoveryModel().recoverableArtifacts.length > 0}> <Show when={view() === 'date' && recoveryModel().recoverableArtifacts.length > 0}>
<BackupActivityChart <Show
title={ when={hasRecoverableActivityInRange()}
recoverableMetricMode() === 'volume' ? 'Backup volume per day' : 'Backups per day' 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} <BackupActivityChart
range={chartRange} title={
onRangeChange={setChartRange} recoverableMetricMode() === 'volume' ? 'Backup volume per day' : 'Backups per day'
timeline={recoverableTimeline} }
selectedDateKey={selectedDateKey} noun="backup"
onToggleDay={toggleDay} segmentKinds={RECOVERABLE_SEGMENT_KINDS}
metricToggle={{ mode: recoverableMetricMode, onChange: setRecoverableMetricMode }} range={chartRange}
/> onRangeChange={setChartRange}
timeline={recoverableTimeline}
selectedDateKey={selectedDateKey}
onToggleDay={toggleDay}
metricToggle={{ mode: recoverableMetricMode, onChange: setRecoverableMetricMode }}
/>
</Show>
</Show> </Show>
<FilterBar <FilterBar
@@ -170,7 +170,7 @@ afterEach(() => {
}); });
describe('ProxmoxBackupsTable', () => { 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(); mockBackupAPIs();
renderInRouter(() => ( renderInRouter(() => (
@@ -181,10 +181,17 @@ describe('ProxmoxBackupsTable', () => {
/> />
)); ));
// Default view is the v5-parity backup feed: one row PER backup, so the await screen.findAllByText('pbs-docker');
// guest with PBS + archive + snapshot artifacts appears on multiple rows, expect(screen.getByRole('button', { name: /coverage/i })).toHaveAttribute(
// sourced and located, not collapsed to a single coverage-posture summary. 'aria-pressed',
expect((await screen.findAllByText('pbs-docker')).length).toBeGreaterThan(1); '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: /location/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /source/i })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: /source/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /type/i })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: /type/i })).toBeInTheDocument();
@@ -273,6 +280,7 @@ describe('ProxmoxBackupsTable', () => {
)); ));
await screen.findAllByText('pbs-docker'); await screen.findAllByText('pbs-docker');
await fireEvent.click(screen.getByRole('button', { name: /by date/i }));
const searchInput = screen.getByPlaceholderText(/search backups by workload/i); const searchInput = screen.getByPlaceholderText(/search backups by workload/i);
await fireEvent.input(searchInput, { target: { value: 'no-such-guest' } }); await fireEvent.input(searchInput, { target: { value: 'no-such-guest' } });
@@ -839,8 +839,7 @@ const availabilityAddressFor = (machine: Resource): string => {
const address = asTrimmedString(availability?.address); const address = asTrimmedString(availability?.address);
if (address) return address; if (address) return address;
const identityWithIPAddresses = machine.identity as const identityWithIPAddresses = machine.identity as
| (Resource['identity'] & { ipAddresses?: string[] }) (Resource['identity'] & { ipAddresses?: string[] }) | undefined;
| undefined;
const firstIP = asTrimmedString( const firstIP = asTrimmedString(
identityWithIPAddresses?.ipAddresses?.[0] ?? machine.identity?.ips?.[0], identityWithIPAddresses?.ipAddresses?.[0] ?? machine.identity?.ips?.[0],
); );
@@ -1179,6 +1178,10 @@ export const AgentsMachinesTable: Component<{
const columnVisibility = useColumnVisibility( const columnVisibility = useColumnVisibility(
'pulse:standalone:machines:columns:v3', 'pulse:standalone:machines:columns:v3',
AGENT_MACHINE_COLUMNS, AGENT_MACHINE_COLUMNS,
[],
undefined,
{},
['network', 'diskio', 'uptime', 'temp'],
); );
const drawer = createPlatformResourceDetailState({ idPrefix: 'agents-machine-drawer' }); const drawer = createPlatformResourceDetailState({ idPrefix: 'agents-machine-drawer' });
const [agentMetadataById, setAgentMetadataById] = createSignal<Record<string, AgentMetadata>>({}); const [agentMetadataById, setAgentMetadataById] = createSignal<Record<string, AgentMetadata>>({});
@@ -1405,7 +1408,7 @@ export const AgentsMachinesTable: Component<{
> >
<PlatformTableShell <PlatformTableShell
title="Machines" 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={ header={
<> <>
<For each={visibleColumns()}> <For each={visibleColumns()}>
@@ -1794,6 +1797,7 @@ export const AgentsMachinesTable: Component<{
open={isExpanded()} open={isExpanded()}
detailRowId={detailRowId()} detailRowId={detailRowId()}
colSpan={detailColspan()} colSpan={detailColspan()}
initialShowHostDetails={false}
onClose={() => { onClose={() => {
drawer.close(machine); drawer.close(machine);
}} }}
@@ -8,10 +8,14 @@ import { RESOURCE_METADATA_CHANGED_EVENT } from '@/utils/resourceMetadataEvents'
import { AgentsMachinesTable } from '../AgentsMachinesTable'; import { AgentsMachinesTable } from '../AgentsMachinesTable';
vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({ vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
ResourceDetailDrawer: (props: { initialShowAccessContext?: boolean }) => ( ResourceDetailDrawer: (props: {
initialShowAccessContext?: boolean;
initialShowHostDetails?: boolean;
}) => (
<div <div
data-testid="resource-detail-drawer" data-testid="resource-detail-drawer"
data-initial-show-access-context={String(props.initialShowAccessContext ?? false)} 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 Net I/O' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sort by Disk I/O' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Sort by Disk I/O' })).toBeInTheDocument();
expect(screen.getByText('1.00 KB/s')).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.queryByRole('button', { name: 'Sort by IP' })).not.toBeInTheDocument();
expect(screen.getByText('192.168.0.10')).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('IP'));
await fireEvent.click(screen.getByLabelText('RAID')); await fireEvent.click(screen.getByLabelText('RAID'));
@@ -512,7 +521,10 @@ describe('AgentsMachinesTable', () => {
expect( expect(
screen.getByRole('button', { name: 'Collapse details for Expandable Machine' }), screen.getByRole('button', { name: 'Collapse details for Expandable Machine' }),
).toHaveAttribute('aria-expanded', 'true'); ).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 () => { 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"]'); const trigger = container.querySelector('[data-agent-machine-network-trigger="true"]');
expect(trigger).not.toBeNull(); expect(trigger).not.toBeNull();
if (!trigger) return; 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"]'); const trigger = container.querySelector('[data-agent-machine-diskio-trigger="true"]');
expect(trigger).not.toBeNull(); expect(trigger).not.toBeNull();
if (!trigger) return; 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"]'); const trigger = container.querySelector('[data-agent-machine-temperature-trigger="true"]');
expect(trigger).not.toBeNull(); expect(trigger).not.toBeNull();
if (!trigger) return; 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'); const pressure = await screen.findByText('Nominal');
expect(pressure).toBeInTheDocument(); expect(pressure).toBeInTheDocument();
@@ -54,10 +54,38 @@ export const AGENT_MACHINE_COLUMNS: AgentMachineColumn[] = [
{ id: 'cpu', label: 'CPU', kind: 'metric-bar', sortKey: 'cpu' }, { id: 'cpu', label: 'CPU', kind: 'metric-bar', sortKey: 'cpu' },
{ id: 'memory', label: 'Memory', kind: 'metric-bar', sortKey: 'memory' }, { id: 'memory', label: 'Memory', kind: 'metric-bar', sortKey: 'memory' },
{ id: 'disk', label: 'Disk', kind: 'metric-bar', sortKey: 'disk' }, { 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: 'network',
{ id: 'uptime', label: 'Uptime', kind: 'numeric-value', sortKey: 'uptime', toggleable: true }, label: 'Net I/O',
{ id: 'temp', label: 'Temp', kind: 'numeric-value', sortKey: 'temp', toggleable: true }, 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', id: 'lastSeen',
label: 'Last seen', label: 'Last seen',
+181 -136
View File
@@ -6,6 +6,7 @@ import { useLocation, useNavigate } from '@solidjs/router';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { Card } from '@/components/shared/Card'; import { Card } from '@/components/shared/Card';
import { Button } from '@/components/shared/Button';
import { PageHeader } from '@/components/shared/PageHeader'; import { PageHeader } from '@/components/shared/PageHeader';
import { notificationStore } from '@/stores/notifications'; 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 History from 'lucide-solid/icons/history';
import Gauge from 'lucide-solid/icons/gauge'; import Gauge from 'lucide-solid/icons/gauge';
import Send from 'lucide-solid/icons/send'; import Send from 'lucide-solid/icons/send';
import BellOff from 'lucide-solid/icons/bell-off';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy'; import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import { AlertsConfigurationSurface } from '@/features/alerts/AlertsConfigurationSurface'; import { AlertsConfigurationSurface } from '@/features/alerts/AlertsConfigurationSurface';
import { OverviewTab } from '@/features/alerts/OverviewTab'; import { OverviewTab } from '@/features/alerts/OverviewTab';
@@ -252,162 +254,205 @@ export function Alerts() {
} }
/> />
<Card padding="none" class="relative lg:flex overflow-hidden"> <Show
<div when={!alertsConfigurationLocked()}
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`} fallback={
aria-label={t('alerts.nav.ariaLabel')} <Card padding="none" class="overflow-hidden">
aria-expanded={!sidebarCollapsed()} <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 <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
<div class="flex items-center justify-between pb-2 border-b border-border"> class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
<h2 class="text-sm font-semibold text-base-content">{t('alerts.nav.title')}</h2> >
<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 <button
type="button" type="button"
onClick={() => setSidebarCollapsed(true)} onClick={() => setSidebarCollapsed(false)}
class="p-1 rounded-md hover:bg-surface-hover transition-colors" class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
aria-label={t('alerts.nav.collapseSidebar')} 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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" 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> </svg>
</button> </button>
</div> </Show>
</Show> <div id="alerts-sidebar-menu" class="space-y-5">
<Show when={sidebarCollapsed()}> <For each={tabGroups()}>
<button {(group) => (
type="button" <div class="space-y-2">
onClick={() => setSidebarCollapsed(false)} <Show when={!sidebarCollapsed()}>
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors" <p class="text-xs font-semibold uppercase tracking-wide text-muted">
aria-label={t('alerts.nav.expandSidebar')} {group.label}
> </p>
<svg class="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </Show>
<path <div class="space-y-1.5">
stroke-linecap="round" <For each={group.items}>
stroke-linejoin="round" {(item) => (
stroke-width="2" <button
d="M13 5l7 7-7 7M5 5l7 7-7 7" type="button"
/> aria-current={activeTab() === item.id ? 'page' : undefined}
</svg> aria-disabled={alertsConfigurationLocked()}
</button> disabled={alertsConfigurationLocked()}
</Show> class={getAlertsSidebarTabClass({
<div id="alerts-sidebar-menu" class="space-y-5"> isActive: activeTab() === item.id,
<For each={tabGroups()}> isDisabled: alertsConfigurationLocked(),
{(group) => ( collapsed: sidebarCollapsed(),
<div class="space-y-2"> })}
<Show when={!sidebarCollapsed()}> onClick={() => handleTabChange(item.id)}
<p class="text-xs font-semibold uppercase tracking-wide text-muted"> title={getAlertsTabTitle({
{group.label} isDisabled: alertsConfigurationLocked(),
</p> collapsed: sidebarCollapsed(),
</Show> label: item.label,
<div class="space-y-1.5"> })}
<For each={group.items}> >
{(item) => ( {item.icon}
<button <Show when={!sidebarCollapsed()}>
type="button" <span class="truncate">{item.label}</span>
aria-current={activeTab() === item.id ? 'page' : undefined} </Show>
aria-disabled={alertsConfigurationLocked()} </button>
disabled={alertsConfigurationLocked()} )}
class={getAlertsSidebarTabClass({ </For>
isActive: activeTab() === item.id, </div>
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>
</div>
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<Show when={flatTabs().length > 0}> <Show when={flatTabs().length > 0}>
<div class="lg:hidden border-b border-border"> <div class="lg:hidden border-b border-border">
<div class="p-1"> <div class="p-1">
<div class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide"> <div class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide">
<For each={flatTabs()}> <For each={flatTabs()}>
{(tab) => ( {(tab) => (
<button <button
type="button" type="button"
aria-disabled={alertsConfigurationLocked()} aria-disabled={alertsConfigurationLocked()}
disabled={alertsConfigurationLocked()} disabled={alertsConfigurationLocked()}
class={getAlertsMobileTabClass({ class={getAlertsMobileTabClass({
isActive: activeTab() === tab.id, isActive: activeTab() === tab.id,
isDisabled: alertsConfigurationLocked(), isDisabled: alertsConfigurationLocked(),
})} })}
onClick={() => handleTabChange(tab.id)} onClick={() => handleTabChange(tab.id)}
title={getAlertsTabTitle({ title={getAlertsTabTitle({
isDisabled: alertsConfigurationLocked(), isDisabled: alertsConfigurationLocked(),
label: tab.label, label: tab.label,
})} })}
> >
<span class="w-full text-center truncate block">{tab.label}</span> <span class="w-full text-center truncate block">{tab.label}</span>
</button> </button>
)} )}
</For> </For>
</div>
</div> </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> </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>
</div> </Card>
</Card> </Show>
</div> </div>
); );
} }
@@ -753,16 +753,14 @@ describe('AIIntelligence entitlement gating', () => {
render(() => <AIIntelligence />); render(() => <AIIntelligence />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Check Patrol model' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Patrol needs setup' })).toBeInTheDocument();
}); });
expect( expect(
screen.getByText( 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(); ).toBeInTheDocument();
expect( expect(screen.getByText('Selected model cannot run Patrol tools.')).toBeInTheDocument();
screen.getByText(/Patrol check:\s*Selected model cannot run Patrol tools\./),
).toBeInTheDocument();
expect( expect(
screen.queryByText( screen.queryByText(
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.', '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(/Trigger status:/)).not.toBeInTheDocument();
expect(screen.queryByText(/local development safety guard/)).not.toBeInTheDocument(); expect(screen.queryByText(/local development safety guard/)).not.toBeInTheDocument();
const providerSettingsLinks = screen.getAllByRole('link', { const providerSettingsLinks = screen.getAllByRole('link', {
name: 'Open Provider & Models', name: 'Check Patrol model',
}); });
expect(providerSettingsLinks.length).toBeGreaterThan(0); expect(providerSettingsLinks.length).toBeGreaterThan(0);
expect(providerSettingsLinks[0]).toHaveAttribute( expect(providerSettingsLinks[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol');
'href',
'/settings/pulse-intelligence/provider',
);
expect(screen.queryByRole('button', { name: /Run Patrol/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Run Patrol/i })).not.toBeInTheDocument();
expect(triggerPatrolRunMock).not.toHaveBeenCalled(); expect(triggerPatrolRunMock).not.toHaveBeenCalled();
}); });
@@ -1849,7 +1844,7 @@ describe('AIIntelligence entitlement gating', () => {
render(() => <AIIntelligence />); render(() => <AIIntelligence />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Check Patrol model' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Patrol needs setup' })).toBeInTheDocument();
}); });
expect( expect(
@@ -1857,21 +1852,19 @@ describe('AIIntelligence entitlement gating', () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
screen.getByText( 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(); ).toBeInTheDocument();
expect( expect(screen.getByRole('heading', { name: 'Patrol cannot run yet' })).toBeInTheDocument();
screen.getByRole('heading', { name: 'Patrol model needs attention' }),
).toBeInTheDocument();
expect( expect(
screen.queryByText('Fix the provider connection, then Patrol can check infrastructure.'), screen.queryByText('Fix the provider connection, then Patrol can check infrastructure.'),
).not.toBeInTheDocument(); ).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 setup issue')).not.toBeInTheDocument();
expect(screen.queryByText('Patrol readiness 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', 'href',
'/settings/pulse-intelligence/provider', '/settings/pulse-intelligence/patrol',
); );
expect(screen.queryByText('Runtime issue')).not.toBeInTheDocument(); expect(screen.queryByText('Runtime issue')).not.toBeInTheDocument();
expect(screen.queryByText(/regressed \d+×/)).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(); expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
const providerActions = screen.getAllByRole('link', { name: /Check Patrol model/i }); const providerActions = screen.getAllByRole('link', { name: /Check Patrol model/i });
expect(providerActions.length).toBeGreaterThan(0); expect(providerActions.length).toBeGreaterThan(0);
expect(providerActions[0]).toHaveAttribute('href', '/settings/pulse-intelligence/provider'); expect(providerActions[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol');
expect(screen.queryByRole('link', { name: 'Open Provider & Models' })).not.toBeInTheDocument(); 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 () => { 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, /Checked 72 resources in 58m\. Patrol ended with a runtime issue: Selected model does not support Patrol tools/i,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute( expect(screen.getByRole('link', { name: 'Check Patrol model' })).toHaveAttribute(
'href', 'href',
'/settings/pulse-intelligence/provider', '/settings/pulse-intelligence/patrol',
); );
expect(findingsPanelState.latestProps).toMatchObject({ expect(findingsPanelState.latestProps).toMatchObject({
filterOverride: 'all', filterOverride: 'all',
@@ -169,4 +169,20 @@ describe('Alerts read-only presentation', () => {
expect(screen.getAllByText('Thresholds').length).toBeGreaterThan(0); expect(screen.getAllByText('Thresholds').length).toBeGreaterThan(0);
expect(navigateSpy).not.toHaveBeenCalledWith('/alerts/overview', { replace: true }); 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', label: 'Settings → API Access',
}); });
expect(getDiscoveryServiceContextSettingsTarget()).toEqual({ expect(getDiscoveryServiceContextSettingsTarget()).toEqual({
href: '/settings/pulse-intelligence/discovery', href: '/settings/pulse-intelligence/assistant',
label: 'Open Service Context settings', label: 'Open Assistant settings',
}); });
expect(getDiscoveryNoConnectedAgentMessage(false)).toBe( expect(getDiscoveryNoConnectedAgentMessage(false)).toBe(
'Commands not enabled. Enable Pulse commands from Settings → Infrastructure for this agent.', 'Commands not enabled. Enable Pulse commands from Settings → Infrastructure for this agent.',
@@ -88,8 +88,8 @@ describe('patrolRunPresentation', () => {
error_detail: 'Provider rejected tool_choice', error_detail: 'Provider rejected tool_choice',
}), }),
).toEqual({ ).toEqual({
label: 'Open Provider & Models', label: 'Check Patrol model',
href: '/settings/pulse-intelligence/provider', href: '/settings/pulse-intelligence/patrol',
}); });
expect( expect(
getPatrolRunPrimaryActionPresentation({ getPatrolRunPrimaryActionPresentation({
@@ -126,8 +126,8 @@ describe('patrolRunPresentation', () => {
}), }),
).toEqual({ ).toEqual({
action: { action: {
label: 'Open Provider & Models', label: 'Check Patrol model',
href: '/settings/pulse-intelligence/provider', href: '/settings/pulse-intelligence/patrol',
}, },
summary: 'Checked 72 resources in 58m.', summary: 'Checked 72 resources in 58m.',
outcome: outcome:
@@ -7,8 +7,8 @@ import {
describe('patrolRuntimeActions', () => { describe('patrolRuntimeActions', () => {
it('centralizes the Patrol provider settings action', () => { it('centralizes the Patrol provider settings action', () => {
expect(PATROL_PROVIDER_SETTINGS_ACTION).toEqual({ expect(PATROL_PROVIDER_SETTINGS_ACTION).toEqual({
label: 'Open Provider & Models', label: 'Check Patrol model',
href: '/settings/pulse-intelligence/provider', href: '/settings/pulse-intelligence/patrol',
}); });
const action = getPatrolProviderSettingsAction(); const action = getPatrolProviderSettingsAction();
@@ -372,8 +372,8 @@ describe('getPatrolSummaryPresentation', () => {
] as never, ] as never,
}), }),
).toEqual({ ).toEqual({
label: 'Open Provider & Models', label: 'Check Patrol model',
href: '/settings/pulse-intelligence/provider', href: '/settings/pulse-intelligence/patrol',
}); });
}); });
@@ -745,8 +745,7 @@ describe('getPatrolSummaryPresentation', () => {
}), }),
).toEqual({ ).toEqual({
title: 'Needs full check', title: 'Needs full check',
description: description: 'Recent targeted checks covered 1 resource. Run Patrol to check everything.',
'Recent targeted checks covered 1 resource. Run Patrol to check everything.',
compactLabel: 'Partial check', compactLabel: 'Partial check',
tone: 'warning', tone: 'warning',
}); });
@@ -789,8 +788,7 @@ describe('getPatrolSummaryPresentation', () => {
}), }),
).toEqual({ ).toEqual({
title: 'Needs full check', title: 'Needs full check',
description: description: 'Recent follow-up checks covered 1 resource. Run Patrol to check everything.',
'Recent follow-up checks covered 1 resource. Run Patrol to check everything.',
compactLabel: 'Partial check', compactLabel: 'Partial check',
tone: 'warning', tone: 'warning',
}); });
@@ -7,7 +7,7 @@ import {
getInfrastructureSettingsLocationLabel, getInfrastructureSettingsLocationLabel,
getInfrastructureSettingsTarget, getInfrastructureSettingsTarget,
} from '@/utils/infrastructureSettingsPresentation'; } from '@/utils/infrastructureSettingsPresentation';
import { SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH } from '@/routing/resourceLinks'; import { SETTINGS_PULSE_INTELLIGENCE_ASSISTANT_PATH } from '@/routing/resourceLinks';
export interface DiscoveryIdentifiedSummary { export interface DiscoveryIdentifiedSummary {
serviceName: string; serviceName: string;
@@ -322,8 +322,8 @@ export function getDiscoveryApiAccessSettingsTarget() {
export function getDiscoveryServiceContextSettingsTarget() { export function getDiscoveryServiceContextSettingsTarget() {
return { return {
href: SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH, href: SETTINGS_PULSE_INTELLIGENCE_ASSISTANT_PATH,
label: 'Open Service Context settings', label: 'Open Assistant settings',
} as const; } as const;
} }
@@ -1,4 +1,4 @@
import { SETTINGS_PROVIDER_MODELS_PATH } from '@/components/Settings/settingsNavigationModel'; import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
export interface PatrolRuntimeActionPresentation { export interface PatrolRuntimeActionPresentation {
label: string; label: string;
@@ -6,8 +6,8 @@ export interface PatrolRuntimeActionPresentation {
} }
export const PATROL_PROVIDER_SETTINGS_ACTION: PatrolRuntimeActionPresentation = { export const PATROL_PROVIDER_SETTINGS_ACTION: PatrolRuntimeActionPresentation = {
label: 'Open Provider & Models', label: 'Check Patrol model',
href: SETTINGS_PROVIDER_MODELS_PATH, href: settingsTabPath('system-ai-patrol'),
}; };
export const getPatrolProviderSettingsAction = (): PatrolRuntimeActionPresentation => ({ export const getPatrolProviderSettingsAction = (): PatrolRuntimeActionPresentation => ({