mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Improve monitor-first frontend workflows
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
-4
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -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',
|
||||||
|
|||||||
@@ -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 => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user