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