mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Simplify object drawer headers
This commit is contained in:
@@ -3607,6 +3607,11 @@ automatic-action policy, saved access configuration, and action audit belong
|
||||
to a dedicated `Manage` tab and must not render inside Overview. Guest, node,
|
||||
Docker-host, and unified-resource drawers must compose these shared primitives
|
||||
instead of defining provider-local attention or technical disclosure shells.
|
||||
Drawer headers reserve their limited space for the subject and a close control,
|
||||
plus a direct object-specific lifecycle action only where the object contract
|
||||
requires one. Generic `Ask Assistant` and `Copy context` actions do not belong
|
||||
in object drawer headers: Assistant remains available through the global shell,
|
||||
and raw context export must not compete with the operational reading path.
|
||||
The shared interactive sparkline now follows that same split.
|
||||
`frontend-modern/src/components/shared/InteractiveSparkline.tsx` stays the
|
||||
render shell, `frontend-modern/src/components/shared/useInteractiveSparklineState.ts`
|
||||
|
||||
@@ -1366,6 +1366,10 @@ selected. Alert lookup may consume the already-loaded active-alert map through
|
||||
the resource's bounded canonical/provider identity candidate list; it must not
|
||||
add a per-drawer fetch, rescan the workload inventory, or eagerly mount hidden
|
||||
technical sections.
|
||||
Guest and resource drawer headers must not initialize clipboard-context or
|
||||
Assistant-handoff state for generic header buttons. Removing those controls
|
||||
also removes their drawer-local signal, API, and formatting wiring; the global
|
||||
Assistant shell remains the single generic entry point.
|
||||
|
||||
The investigation enrichment path in `MaybeInvestigateFinding`
|
||||
adds at most one operator-state projection lookup per investigation
|
||||
|
||||
@@ -4659,3 +4659,7 @@ details. Operator overrides, lifecycle policy, update actions, audit controls,
|
||||
and saved web access belong to Manage. Provider pages may supply typed detail
|
||||
models, but they must not create a provider-local drawer hierarchy or promote
|
||||
metadata into Overview solely because it exists.
|
||||
The drawer header contains no generic Assistant handoff or agent-context copy
|
||||
action. Those shell-level capabilities must not consume object identity space;
|
||||
only the close control and a capability-backed object lifecycle action may sit
|
||||
beside the heading.
|
||||
|
||||
@@ -1,65 +1,21 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "a73abe909caceee084e647b959f9adae9a355bb3",
|
||||
"verified_at": "2026-08-24T11:27:51Z",
|
||||
"base_sha": "9e622c291d499cd88b8269ef98c878c702de801b",
|
||||
"verified_at": "2026-08-24T11:47:38Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerManage.tsx",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawer.tsx",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
|
||||
"frontend-modern/src/components/shared/DetailSectionTable.tsx",
|
||||
"frontend-modern/src/components/shared/DrawerAttentionSection.tsx",
|
||||
"frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx",
|
||||
"frontend-modern/src/components/shared/detailSectionModel.ts",
|
||||
"frontend-modern/src/features/alerts/alertOverridesModel.ts",
|
||||
"frontend-modern/src/features/docker/DockerHostDrawer.tsx",
|
||||
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
|
||||
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts",
|
||||
"frontend-modern/src/types/workloads.ts",
|
||||
"frontend-modern/src/utils/alerts.ts",
|
||||
"frontend-modern/src/utils/workloadGuestPresentation.ts"
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "8d759c4043eca6ffc4353e478e8ecc9117eb5cac0ee0799afb186f669802bf09",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "7a69a40a65452affb25c5a136dcfb5a84bb07d3ee948c08eb77e0fe6707bee8f",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx": "040da0942faabe89455b2d478b2fac03697e50ee9c12426a399b2cb0004327dd",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "0d40dbff2b59f63813f98419004fedf1269c99efa29308210e11c3d18ee8d904",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "10dc7c47e116f50f197908b52b88c9acca42ffd35e89106390a5d69f73a00837",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "850cd9dba3b91530068c8542121e4929bebc547b3abda1a10744fa886c0f9361",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "543a17e2bcb03cc7e411aebe63330e575c069e03f78ceb31baa6ca727cb01f57",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerManage.tsx": "8def7d128956c2e24e7f8c0913b7f79f7c9b3f21c54130ed51f4149915f417b5",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "b9bddadb3c2386329a2caae6c8ae6d785e935af35c41c79be9f47f2163463252",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawer.tsx": "f6803503882e380e2d0d3df56d6676607717579da81ed7e24c06894961fb1258",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "5d9f130c58cffe8f653730b961b297fb789df3a5bc79b83c3da874c2067bc31e",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "e5ccf2149958fc5cfe3b0711b9d56557e0c86d525efdef69af229f325a0fe4df",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "faf23c024fd0c0395ef1fbcebb10f13063ed9a4b7dcb5800c2ab836c9d5c5e8b",
|
||||
"frontend-modern/src/components/shared/DetailSectionTable.tsx": "871c66c39f727ae46fbfb6e3246c6ea0acd37d02e9abe6ba7143013913f19e22",
|
||||
"frontend-modern/src/components/shared/DrawerAttentionSection.tsx": "b9035090571c09e791021f789e8e4a4bcaffbff33dd37458ffbebf9a223d319c",
|
||||
"frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx": "d6a8ed7f006cecbe1b2e7055d041e767ff8d200ecb2c8454827bfc397e0dbdeb",
|
||||
"frontend-modern/src/components/shared/detailSectionModel.ts": "b2effcc4ff9ce98e9fdd73d4853cb38166b92487c553e8cd981bb41ff7c11d86",
|
||||
"frontend-modern/src/features/alerts/alertOverridesModel.ts": "8e344f8d76f4038efad5fe2765ba92ef5ca8edc0afbcdbfbc3d4cda9696a1d0a",
|
||||
"frontend-modern/src/features/docker/DockerHostDrawer.tsx": "8bfd9ea8ec7cd8be5a77568aa33774a2c30f6bcc33113044ef4bf1e34422bcc4",
|
||||
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "4adb8252dcac84ed8523f798338091852869f891f1583e130053649affc987a7",
|
||||
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "05bc58b83bb5ecce65243a6849d6ded08bcbb656fcb3eda23d13129463e94ccd",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "65c945d3ae01adeb6f41b2ba144b5b7b523c3e7c118721890c78bd6d19b42efb",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts": "f56021125846e931dd175ed8c4a0c46bd884edce434c61bf5a332a8c0a444f52",
|
||||
"frontend-modern/src/types/workloads.ts": "e878e4d96359b4add4ea650ce5e3af361e6cb15e3fede51a8f7bc9d2c8ca8aa9",
|
||||
"frontend-modern/src/utils/alerts.ts": "6b9556d0415294a8151d786489a4ecb9667b26ddc272c8fe33cf955a9ebcbbeb",
|
||||
"frontend-modern/src/utils/workloadGuestPresentation.ts": "ef23c08bed5f507a163aca438ec8edd2e95e47e20d164a68e0e15b7697941886"
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "4bc28b3a2f26f1e011502a55deb6befe62e001db2d4020c14b016a7d2f1458e5",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "8e1f892548ec92d5e895013c8247b73e6119e909cb9d635dcab354c25d14fd04",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "17291d2c1f574e8bb1a60ca3d933dc6fab6badbd80cd4097542d74db13050429",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "ec24e7f9c32f9ffdf36eebc0391fbd7019af8ed096a01513e81ac54806e315f2"
|
||||
},
|
||||
"routes": ["/proxmox/overview", "/docker/overview", "/truenas/overview"],
|
||||
"routes": ["/truenas/overview", "/proxmox/overview"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
@@ -71,17 +27,14 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Proxmox guest with an active powered-off alert and condensed additive operator context",
|
||||
"Proxmox node with an active offline alert and condensed node context",
|
||||
"Docker host overview and Manage tab with update actions separated from context",
|
||||
"TrueNAS system with multiple active health issues and both technical disclosure groups collapsed",
|
||||
"generic resource Manage tab with operator overrides and saved web access"
|
||||
"TrueNAS system drawer with active health issues and only the close control in its header",
|
||||
"Proxmox guest drawer with only the close control in its header",
|
||||
"global platform Assistant entry remaining available outside the object drawer"
|
||||
],
|
||||
"interactions": [
|
||||
"expanded guest, node, Docker host, and TrueNAS resource drawers",
|
||||
"confirmed exact active alert messages render immediately and wrap at phone width",
|
||||
"expanded Technical details and Platform details independently",
|
||||
"switched to Manage and confirmed overrides, actions, and access are absent from Overview",
|
||||
"confirmed the hierarchy at desktop width and restored the temporary browser viewport"
|
||||
"expanded a TrueNAS resource drawer at phone width and confirmed no object Assistant or context-copy controls",
|
||||
"expanded a Proxmox guest drawer at phone width and confirmed no object Assistant or context-copy controls",
|
||||
"confirmed the guest header rule at desktop width",
|
||||
"restored the temporary browser viewport"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,6 +23,26 @@
|
||||
"path": "src/features/docker/DockerHostDrawerOverview.tsx"
|
||||
}
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"patterns": [
|
||||
"Ask Pulse Assistant about",
|
||||
"Copy Pulse context for",
|
||||
"openAssistantForResource",
|
||||
"copyAgentContext"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/components/Workloads/GuestDrawer.tsx",
|
||||
"patterns": [
|
||||
"Ask Pulse Assistant about",
|
||||
"Copy Pulse context for",
|
||||
"openAssistantForGuest",
|
||||
"copyAgentContext"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/Workloads/GuestDrawer.test.tsx",
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Show, For, Suspense } from 'solid-js';
|
||||
import type { Component } from 'solid-js';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
DrawerHeaderActionButton,
|
||||
DrawerHeaderActionGroup,
|
||||
DrawerHeaderIconButton,
|
||||
} from '@/components/shared/Button';
|
||||
import { DrawerHeaderActionGroup, DrawerHeaderIconButton } from '@/components/shared/Button';
|
||||
import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { Subtabs } from '@/components/shared/Subtabs';
|
||||
@@ -129,25 +123,6 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
||||
onActionSettled={props.onResourceActionSettled}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={drawer.assistantAvailable()}>
|
||||
<DrawerHeaderActionButton
|
||||
onClick={() => drawer.openAssistantForResource()}
|
||||
title={`Ask Pulse Assistant about ${drawer.displayName()}`}
|
||||
aria-label={`Ask Pulse Assistant about ${drawer.displayName()}`}
|
||||
>
|
||||
<MessageSquareIcon class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Ask</span>
|
||||
</DrawerHeaderActionButton>
|
||||
</Show>
|
||||
<DrawerHeaderActionButton
|
||||
onClick={() => void drawer.copyAgentContext()}
|
||||
disabled={drawer.copyingAgentContext()}
|
||||
title={`Copy Pulse context for ${drawer.displayName()}`}
|
||||
aria-label={`Copy Pulse context for ${drawer.displayName()}`}
|
||||
>
|
||||
<CopyIcon class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">{drawer.agentContextCopied() ? 'Copied' : 'Copy'}</span>
|
||||
</DrawerHeaderActionButton>
|
||||
<Show when={props.onClose}>
|
||||
<DrawerHeaderIconButton
|
||||
onClick={() => props.onClose?.()}
|
||||
|
||||
+7
-97
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render as solidRender, waitFor, within } from '@solidjs/testing-library';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import {
|
||||
ResourceDetailDrawer,
|
||||
getSpecializedTabAvailabilityMessage,
|
||||
@@ -29,10 +28,6 @@ const render = (...args: Parameters<typeof solidRender>): ReturnType<typeof soli
|
||||
|
||||
const wsState = vi.hoisted(() => ({ pmg: [] as any[] }));
|
||||
const reconnectSpy = vi.hoisted(() => vi.fn());
|
||||
const getResourceContextMock = vi.hoisted(() => vi.fn());
|
||||
const copyToClipboardMock = vi.hoisted(() => vi.fn());
|
||||
const notificationSuccessMock = vi.hoisted(() => vi.fn());
|
||||
const notificationErrorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/contexts/appRuntime', () => ({
|
||||
useWebSocket: () => ({
|
||||
@@ -104,23 +99,6 @@ vi.mock('@/api/ai', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/agentContext', () => ({
|
||||
AgentContextAPI: {
|
||||
getResourceContext: getResourceContextMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: notificationSuccessMock,
|
||||
error: notificationErrorMock,
|
||||
},
|
||||
}));
|
||||
|
||||
class ResizeObserverMock {
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
observe() {}
|
||||
@@ -146,84 +124,16 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
aiChatStore.setEnabled(false);
|
||||
getResourceContextMock.mockReset();
|
||||
copyToClipboardMock.mockReset();
|
||||
notificationSuccessMock.mockReset();
|
||||
notificationErrorMock.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ResourceDetailDrawer runtime and identity cards', () => {
|
||||
it('opens Pulse Assistant with a resource-scoped handoff from the drawer header', () => {
|
||||
aiChatStore.setEnabled(true);
|
||||
const openSpy = vi.spyOn(aiChatStore, 'open');
|
||||
const resource = baseResource({
|
||||
type: 'app-container',
|
||||
displayName: 'Home Assistant',
|
||||
technology: 'docker',
|
||||
parentName: 'ha-lxc',
|
||||
discoveryTarget: {
|
||||
resourceType: 'app-container',
|
||||
agentId: 'agent:pve-1',
|
||||
resourceId: 'homeassistant',
|
||||
},
|
||||
});
|
||||
it('keeps generic Assistant and context-copy actions out of the drawer header', () => {
|
||||
const { queryByRole } = render(() => (
|
||||
<ResourceDetailDrawer resource={baseResource({ displayName: 'Home Assistant' })} />
|
||||
));
|
||||
|
||||
const { getByRole } = render(() => <ResourceDetailDrawer resource={resource} />);
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Ask Pulse Assistant about Home Assistant' }));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetType: 'resource',
|
||||
targetId: 'resource-1',
|
||||
autonomousMode: false,
|
||||
handoffResources: [
|
||||
{
|
||||
id: 'resource-1',
|
||||
name: 'Home Assistant',
|
||||
type: 'app-container',
|
||||
node: 'ha-lxc',
|
||||
},
|
||||
],
|
||||
handoffMetadata: { kind: 'resource_context' },
|
||||
}),
|
||||
);
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('copies the canonical agent resource context from the drawer header', async () => {
|
||||
getResourceContextMock.mockResolvedValue({
|
||||
canonicalId: 'resource-1',
|
||||
resourceType: 'agent',
|
||||
resourceName: 'host-1',
|
||||
activeFindings: [],
|
||||
pendingApprovals: [],
|
||||
recentActions: [],
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
contextSections: [
|
||||
{
|
||||
id: 'identity',
|
||||
title: 'Identity',
|
||||
source: 'unified-resource',
|
||||
trustTier: 'pulse-authored',
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
facts: [{ label: 'Display name', value: 'host-1' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
|
||||
const { getByRole } = render(() => <ResourceDetailDrawer resource={baseResource({})} />);
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Copy Pulse context for host-1' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResourceContextMock).toHaveBeenCalledWith('resource-1');
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining('host-1'));
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.');
|
||||
});
|
||||
expect(queryByRole('button', { name: 'Ask Pulse Assistant about Home Assistant' })).toBeNull();
|
||||
expect(queryByRole('button', { name: 'Copy Pulse context for Home Assistant' })).toBeNull();
|
||||
});
|
||||
|
||||
it('omits empty operator context without repeating healthy source status', () => {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { createEffect, createSignal } from 'solid-js';
|
||||
import { AgentContextAPI } from '@/api/agentContext';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||
import { useResourceDetailDrawerDockerActionsState } from './useResourceDetailDrawerDockerActionsState';
|
||||
import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState';
|
||||
import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState';
|
||||
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
|
||||
import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
|
||||
|
||||
type DrawerTab =
|
||||
@@ -42,8 +36,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
);
|
||||
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
|
||||
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
|
||||
const [showReportModal, setShowReportModal] = createSignal(false);
|
||||
const [showHistoryFilters, setShowHistoryFilters] = createSignal(false);
|
||||
const [showAccessContext, setShowAccessContext] = createSignal(
|
||||
@@ -123,35 +115,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
}
|
||||
};
|
||||
|
||||
const assistantAvailable = () => aiChatStore.enabled === true;
|
||||
|
||||
const openAssistantForResource = () => {
|
||||
if (!assistantAvailable()) return;
|
||||
aiChatStore.open(buildResourceAssistantContext(resource));
|
||||
};
|
||||
|
||||
const copyAgentContext = async () => {
|
||||
if (copyingAgentContext()) return;
|
||||
setCopyingAgentContext(true);
|
||||
setAgentContextCopied(false);
|
||||
|
||||
try {
|
||||
const context = await AgentContextAPI.getResourceContext(resource.id);
|
||||
const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context));
|
||||
if (!copiedContext) {
|
||||
throw new Error('Clipboard unavailable');
|
||||
}
|
||||
setAgentContextCopied(true);
|
||||
notificationStore.success('Resource context copied.');
|
||||
setTimeout(() => setAgentContextCopied(false), 2000);
|
||||
} catch {
|
||||
notificationStore.error('Unable to copy resource context.');
|
||||
setAgentContextCopied(false);
|
||||
} finally {
|
||||
setCopyingAgentContext(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
@@ -159,8 +122,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
setMetricsHistoryRange,
|
||||
debugEnabled,
|
||||
copied,
|
||||
copyingAgentContext,
|
||||
agentContextCopied,
|
||||
showReportModal,
|
||||
setShowReportModal,
|
||||
showHistoryFilters,
|
||||
@@ -190,9 +151,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
...history,
|
||||
...derived,
|
||||
...dockerActions,
|
||||
assistantAvailable,
|
||||
openAssistantForResource,
|
||||
copyAgentContext,
|
||||
handleCopyJson,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Suspense } from 'solid-js';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads';
|
||||
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
|
||||
import guestDrawerSource from './GuestDrawer.tsx?raw';
|
||||
@@ -69,10 +68,6 @@ const discoveryApiMocks = vi.hoisted(() => ({
|
||||
null,
|
||||
),
|
||||
}));
|
||||
const getResourceContextMock = vi.hoisted(() => vi.fn());
|
||||
const copyToClipboardMock = vi.hoisted(() => vi.fn());
|
||||
const notificationSuccessMock = vi.hoisted(() => vi.fn());
|
||||
const notificationErrorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/api/discovery', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/discovery')>('@/api/discovery');
|
||||
@@ -82,23 +77,6 @@ vi.mock('@/api/discovery', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/agentContext', () => ({
|
||||
AgentContextAPI: {
|
||||
getResourceContext: getResourceContextMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: notificationSuccessMock,
|
||||
error: notificationErrorMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/WebInterfaceUrlField', () => ({
|
||||
WebInterfaceUrlField: (props: {
|
||||
metadataKind: string;
|
||||
@@ -217,7 +195,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
aiChatStore.setEnabled(false);
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -277,99 +254,15 @@ describe('GuestDrawer', () => {
|
||||
expect(screen.queryByTestId('guest-manage-tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Assistant context actions', () => {
|
||||
it('opens Pulse Assistant with a canonical Proxmox LXC resource handoff', () => {
|
||||
aiChatStore.setEnabled(true);
|
||||
const openSpy = vi.spyOn(aiChatStore, 'open');
|
||||
const guest = makeGuest({
|
||||
id: 'legacy-row-id',
|
||||
instance: 'pve-main',
|
||||
node: 'pve-a',
|
||||
vmid: 101,
|
||||
name: 'homeassistant',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
platformType: 'proxmox',
|
||||
status: 'running',
|
||||
discoveryTarget: {
|
||||
resourceType: 'system-container',
|
||||
agentId: 'agent-pve-a',
|
||||
resourceId: '101',
|
||||
},
|
||||
});
|
||||
it('keeps generic Assistant and context-copy actions out of the drawer header', () => {
|
||||
render(() => <GuestDrawer guest={makeGuest({ name: 'homeassistant' })} onClose={vi.fn()} />);
|
||||
|
||||
render(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Ask Pulse Assistant about homeassistant' }),
|
||||
);
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetType: 'resource',
|
||||
targetId: 'pve-main:pve-a:101',
|
||||
autonomousMode: false,
|
||||
handoffResources: [
|
||||
{
|
||||
id: 'pve-main:pve-a:101',
|
||||
name: 'homeassistant',
|
||||
type: 'system-container',
|
||||
node: 'pve-a',
|
||||
},
|
||||
],
|
||||
handoffMetadata: { kind: 'resource_context' },
|
||||
}),
|
||||
);
|
||||
expect(openSpy.mock.calls[0]?.[0]?.handoffContext).toBeUndefined();
|
||||
expect(openSpy.mock.calls[0]?.[0]?.briefing?.detailLines).toContain(
|
||||
'Discovery: system-container:101',
|
||||
);
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('copies the safe agent context bundle using canonical guest identity', async () => {
|
||||
getResourceContextMock.mockResolvedValue({
|
||||
canonicalId: 'pve-main:pve-a:101',
|
||||
resourceType: 'system-container',
|
||||
resourceName: 'homeassistant',
|
||||
activeFindings: [],
|
||||
pendingApprovals: [],
|
||||
recentActions: [],
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
contextSections: [
|
||||
{
|
||||
id: 'runtime',
|
||||
title: 'Runtime',
|
||||
source: 'discovery',
|
||||
trustTier: 'pulse-observed',
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
facts: [{ label: 'Service', value: 'Home Assistant' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
const guest = makeGuest({
|
||||
id: 'legacy-row-id',
|
||||
instance: 'pve-main',
|
||||
node: 'pve-a',
|
||||
vmid: 101,
|
||||
name: 'homeassistant',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
});
|
||||
|
||||
render(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Pulse context for homeassistant' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResourceContextMock).toHaveBeenCalledWith('pve-main:pve-a:101');
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Pulse resource context: homeassistant'),
|
||||
);
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.');
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Ask Pulse Assistant about homeassistant' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Copy Pulse context for homeassistant' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ── Tabs ──
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Component, Show, Suspense, createMemo } from 'solid-js';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import { DiscoveryTab } from '../Discovery/DiscoveryTab';
|
||||
import {
|
||||
DrawerHeaderActionButton,
|
||||
DrawerHeaderActionGroup,
|
||||
DrawerHeaderIconButton,
|
||||
} from '@/components/shared/Button';
|
||||
import { DrawerHeaderActionGroup, DrawerHeaderIconButton } from '@/components/shared/Button';
|
||||
import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback';
|
||||
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
||||
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
|
||||
@@ -25,9 +19,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
agentHeading,
|
||||
agentLabel,
|
||||
agentTitle,
|
||||
agentContextCopied,
|
||||
backupPresentation,
|
||||
copyingAgentContext,
|
||||
discoveryAgentId,
|
||||
discoveryIdentifiedSummary,
|
||||
discoveryLoadingState,
|
||||
@@ -49,9 +41,6 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
guestOsSummary,
|
||||
networkInterfaces,
|
||||
normalizedTags,
|
||||
assistantAvailable,
|
||||
copyAgentContext,
|
||||
openAssistantForGuest,
|
||||
setHistoryRange,
|
||||
showInGuestAgentInstallCue,
|
||||
switchTab,
|
||||
@@ -74,25 +63,6 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
statusLabel={headerIndicator().label}
|
||||
/>
|
||||
<DrawerHeaderActionGroup>
|
||||
<Show when={assistantAvailable()}>
|
||||
<DrawerHeaderActionButton
|
||||
onClick={() => openAssistantForGuest()}
|
||||
title={`Ask Pulse Assistant about ${props.guest.name}`}
|
||||
aria-label={`Ask Pulse Assistant about ${props.guest.name}`}
|
||||
>
|
||||
<MessageSquareIcon class="h-4 w-4" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">Ask</span>
|
||||
</DrawerHeaderActionButton>
|
||||
</Show>
|
||||
<DrawerHeaderActionButton
|
||||
onClick={() => void copyAgentContext()}
|
||||
disabled={copyingAgentContext()}
|
||||
title={`Copy Pulse context for ${props.guest.name}`}
|
||||
aria-label={`Copy Pulse context for ${props.guest.name}`}
|
||||
>
|
||||
<CopyIcon class="h-4 w-4" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">{agentContextCopied() ? 'Copied' : 'Copy'}</span>
|
||||
</DrawerHeaderActionButton>
|
||||
<DrawerHeaderIconButton onClick={() => props.onClose()} aria-label="Close guest drawer">
|
||||
<XIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</DrawerHeaderIconButton>
|
||||
|
||||
+1
-1
@@ -1570,7 +1570,7 @@ describe('Workloads performance contract', () => {
|
||||
expect(guestDrawerSource).toContain('GuestDrawerOverview');
|
||||
expect(guestDrawerSource).toContain('GuestDrawerHistoryRangeSelect');
|
||||
expect(guestDrawerSource).toContain('DrawerHeaderActionGroup');
|
||||
expect(guestDrawerSource).toContain('DrawerHeaderActionButton');
|
||||
expect(guestDrawerSource).not.toContain('DrawerHeaderActionButton');
|
||||
expect(guestDrawerSource).toContain('DrawerHeaderIconButton');
|
||||
expect(guestDrawerSource).not.toContain('Open related infrastructure');
|
||||
expect(guestDrawerHistorySource).toContain('data-testid="guest-history-hover-time"');
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||
|
||||
import { AgentContextAPI } from '@/api/agentContext';
|
||||
import { getDiscovery } from '@/api/discovery';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import type { ResourceDiscovery, ResourceType as DiscoveryResourceType } from '@/types/discovery';
|
||||
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import {
|
||||
getDiscoveryIdentifiedSummary,
|
||||
getDiscoveryLoadingState,
|
||||
@@ -44,7 +39,6 @@ import {
|
||||
type GuestDrawerProps,
|
||||
type GuestDrawerTab,
|
||||
} from './guestDrawerModel';
|
||||
import { buildGuestAssistantContext } from './guestAssistantContextModel';
|
||||
import {
|
||||
getWorkloadActionAgentTitle,
|
||||
hasExplicitWorkloadActionAgent,
|
||||
@@ -63,8 +57,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
);
|
||||
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
|
||||
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
|
||||
|
||||
const guestId = createMemo(() => getCanonicalWorkloadId(props.guest));
|
||||
const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest));
|
||||
@@ -161,35 +153,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
const assistantAvailable = () => aiChatStore.enabled === true;
|
||||
|
||||
const openAssistantForGuest = () => {
|
||||
if (!assistantAvailable()) return;
|
||||
aiChatStore.open(buildGuestAssistantContext(props.guest));
|
||||
};
|
||||
|
||||
const copyAgentContext = async () => {
|
||||
if (copyingAgentContext()) return;
|
||||
setCopyingAgentContext(true);
|
||||
setAgentContextCopied(false);
|
||||
|
||||
try {
|
||||
const context = await AgentContextAPI.getResourceContext(guestId());
|
||||
const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context));
|
||||
if (!copiedContext) {
|
||||
throw new Error('Clipboard unavailable');
|
||||
}
|
||||
setAgentContextCopied(true);
|
||||
notificationStore.success('Resource context copied.');
|
||||
setTimeout(() => setAgentContextCopied(false), 2000);
|
||||
} catch {
|
||||
notificationStore.error('Unable to copy resource context.');
|
||||
setAgentContextCopied(false);
|
||||
} finally {
|
||||
setCopyingAgentContext(false);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (activeTab() === 'discovery' && !hasDiscoverySupport()) {
|
||||
setActiveTab('overview');
|
||||
@@ -205,8 +168,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
agentLabel,
|
||||
agentTitle,
|
||||
backupPresentation,
|
||||
copyingAgentContext,
|
||||
agentContextCopied,
|
||||
discoveryAgentId,
|
||||
discoveryIdentifiedSummary,
|
||||
discoveryReadinessPresentation,
|
||||
@@ -232,9 +193,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
osVersion,
|
||||
showInGuestAgentInstallCue,
|
||||
workloadActionAgentTitle,
|
||||
assistantAvailable,
|
||||
openAssistantForGuest,
|
||||
copyAgentContext,
|
||||
switchTab,
|
||||
setHistoryRange,
|
||||
webInterfaceMetadataId,
|
||||
|
||||
@@ -4184,7 +4184,7 @@ describe('shared primitive guardrails', () => {
|
||||
for (const drawerSource of [guestDrawerSource, resourceDetailDrawerSource]) {
|
||||
expect(drawerSource).toContain('@/components/shared/Button');
|
||||
expect(drawerSource).toContain('DrawerHeaderActionGroup');
|
||||
expect(drawerSource).toContain('DrawerHeaderActionButton');
|
||||
expect(drawerSource).not.toContain('DrawerHeaderActionButton');
|
||||
expect(drawerSource).toContain('DrawerHeaderIconButton');
|
||||
expect(drawerSource).not.toContain(
|
||||
'inline-flex h-8 min-h-11 min-w-11 items-center gap-1.5 rounded border border-border bg-surface px-2 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
|
||||
@@ -8509,6 +8509,25 @@ describe('shared primitive guardrails', () => {
|
||||
expect(source).toContain('ResourceOperatorStateSection');
|
||||
}
|
||||
|
||||
for (const headerPath of [
|
||||
'src/components/Infrastructure/ResourceDetailDrawer.tsx',
|
||||
'src/components/Workloads/GuestDrawer.tsx',
|
||||
]) {
|
||||
const source = readFrontendSource(headerPath);
|
||||
expect(source).not.toContain('Ask Pulse Assistant about');
|
||||
expect(source).not.toContain('Copy Pulse context for');
|
||||
expect(source).not.toContain('copyAgentContext');
|
||||
}
|
||||
|
||||
for (const statePath of [
|
||||
'src/components/Infrastructure/useResourceDetailDrawerState.ts',
|
||||
'src/components/Workloads/useGuestDrawerState.ts',
|
||||
]) {
|
||||
const source = readFrontendSource(statePath);
|
||||
expect(source).not.toContain('AgentContextAPI');
|
||||
expect(source).not.toContain('formatAgentResourceContextForClipboard');
|
||||
}
|
||||
|
||||
const disclosureSource = readFrontendSource(
|
||||
'src/components/shared/TechnicalDetailsDisclosure.tsx',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user