Simplify resource drawer overview hierarchy

This commit is contained in:
rcourtman
2026-03-19 21:41:32 +00:00
parent 229b19fa25
commit efcbb9f137
4 changed files with 181 additions and 111 deletions
@@ -241,6 +241,10 @@ instead of surfacing a peer `History` tab, so resource investigation stays on
one coherent runtime surface: the overview card carries the compact recent
activity summary, while the embedded change-history section owns filters and
the event log without duplicating a second timeline-summary card.
That same overview now keeps AI intelligence and data-governance details
inside a collapsed `Investigation context` disclosure, so runtime status and
identity stay primary while secondary AI and policy signals remain available
without competing with the first-screen monitoring story.
The same facet bundle now also returns grouped recent-change counts by
canonical change kind, so the detail drawer can surface the distribution of
state transitions, restarts, config updates, and anomalies without
@@ -141,6 +141,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
const [copied, setCopied] = createSignal(false);
const [showReportModal, setShowReportModal] = createSignal(false);
const [showInvestigationContext, setShowInvestigationContext] = createSignal(false);
const displayName = createMemo(() => getPreferredResourceDisplayName(props.resource));
const kubernetesClusterName = createMemo(() =>
@@ -307,6 +308,27 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
const resourceDependencies = createMemo(() => resourceIntelligence()?.dependencies ?? []);
const resourceDependents = createMemo(() => resourceIntelligence()?.dependents ?? []);
const resourceCorrelations = createMemo(() => resourceIntelligence()?.correlations ?? []);
const hasInvestigationContext = createMemo(
() => Boolean(resourceIntelligence()) || hasGovernanceData(),
);
const investigationContextSummary = createMemo(() => {
const intel = resourceIntelligence();
const summary: string[] = [];
if (intel) {
summary.push(`AI health ${intel.health.grade} · ${Math.round(intel.health.score)}/100`);
}
if (resourceCorrelations().length > 0) {
summary.push(
`${resourceCorrelations().length} correlation${resourceCorrelations().length === 1 ? '' : 's'}`,
);
}
if (props.resource.policy?.routing.scope) {
summary.push(`Routing ${getResourceRoutingScopeLabel(props.resource.policy.routing.scope)}`);
}
return summary.join(' · ');
});
const timelineFacetRequest = createMemo(() => {
const id = resourceFacetId();
if (!id) return null;
@@ -791,56 +813,6 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
</Show>
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3 mt-3">
<Show when={resourceIntelligence()}>
{(intel) => (
<div class="rounded border border-border bg-surface p-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
AI Intelligence
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Health</span>
<span class="font-medium text-base-content">
{intel().health.grade} · {Math.round(intel().health.score)}/100
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Trend</span>
<span class="font-medium text-base-content capitalize">
{intel().health.trend}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Notes</span>
<span class="font-medium text-base-content">{intel().note_count}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Recent Changes</span>
<span class="font-medium text-base-content">
{intel().recent_changes?.length ?? 0}
</span>
</div>
<ResourceChangeSummary
class="space-y-0"
title="Latest canonical change"
changes={intel().recent_changes}
resolveResourceLabel={resolveResourceLabel}
maxChanges={1}
compact
/>
<ResourceCorrelationSummary
title="Correlation context"
dependencies={resourceDependencies()}
dependents={resourceDependents()}
correlations={resourceCorrelations()}
resolveResourceLabel={resolveResourceLabel}
showLastSeen
/>
</div>
</div>
)}
</Show>
<div class="rounded border border-border bg-surface p-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Runtime
@@ -1003,52 +975,6 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
</div>
</div>
<Show when={hasGovernanceData()}>
<div class="rounded border border-border bg-surface p-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Data Governance
</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.resource.policy}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Sensitivity</span>
<span class="font-medium text-base-content">
{getResourceSensitivityLabel(props.resource.policy?.sensitivity)}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Routing</span>
<span class="font-medium text-base-content">
{getResourceRoutingScopeLabel(props.resource.policy?.routing.scope)}
</span>
</div>
</Show>
<Show when={policyRedactions().length > 0}>
<div class="flex flex-col gap-1">
<span class="text-muted">Redactions</span>
<div class="flex flex-wrap gap-1">
<For each={policyRedactions()}>
{(label) => (
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[10px]">
{label}
</span>
)}
</For>
</div>
</div>
</Show>
<Show when={governanceSummary()}>
<div class="flex flex-col gap-1">
<span class="text-muted">AI-Safe Summary</span>
<div class="rounded border border-border bg-surface-hover px-2 py-1.5 text-[10px] text-base-content">
{governanceSummary()}
</div>
</div>
</Show>
</div>
</div>
</Show>
<Show when={props.resource.type === 'docker-host'}>
<div class="rounded border border-sky-200 bg-sky-50 p-3 dark:border-sky-700 dark:bg-sky-900">
<div class="mb-2 flex items-center justify-between gap-2">
@@ -1417,6 +1343,131 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
</Show>
</div>
<Show when={hasInvestigationContext()}>
<div
data-testid="resource-investigation-context"
class="mt-3 rounded border border-border bg-surface p-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content">
Investigation context
</div>
<div class="mt-1 text-[10px] text-muted">
Secondary AI and policy signals for deeper investigation.
</div>
<Show when={investigationContextSummary()}>
<div class="mt-1 text-[10px] text-base-content">
{investigationContextSummary()}
</div>
</Show>
</div>
<button
type="button"
onClick={() => setShowInvestigationContext((value) => !value)}
class="inline-flex items-center rounded-md border border-border bg-surface-hover px-3 py-1.5 text-[11px] font-medium text-base-content transition-colors hover:bg-surface"
>
{showInvestigationContext() ? 'Hide context' : 'Show context'}
</button>
</div>
<Show when={showInvestigationContext()}>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<Show when={resourceIntelligence()}>
{(intel) => (
<div class="rounded border border-border bg-surface p-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
AI Intelligence
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Health</span>
<span class="font-medium text-base-content">
{intel().health.grade} · {Math.round(intel().health.score)}/100
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Trend</span>
<span class="font-medium text-base-content capitalize">
{intel().health.trend}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Notes</span>
<span class="font-medium text-base-content">{intel().note_count}</span>
</div>
<ResourceChangeSummary
class="space-y-0"
title="Latest canonical change"
changes={intel().recent_changes}
resolveResourceLabel={resolveResourceLabel}
maxChanges={1}
compact
/>
<ResourceCorrelationSummary
title="Correlation context"
dependencies={resourceDependencies()}
dependents={resourceDependents()}
correlations={resourceCorrelations()}
resolveResourceLabel={resolveResourceLabel}
showLastSeen
/>
</div>
</div>
)}
</Show>
<Show when={hasGovernanceData()}>
<div class="rounded border border-border bg-surface p-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Data Governance
</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.resource.policy}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Sensitivity</span>
<span class="font-medium text-base-content">
{getResourceSensitivityLabel(props.resource.policy?.sensitivity)}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Routing</span>
<span class="font-medium text-base-content">
{getResourceRoutingScopeLabel(props.resource.policy?.routing.scope)}
</span>
</div>
</Show>
<Show when={policyRedactions().length > 0}>
<div class="flex flex-col gap-1">
<span class="text-muted">Redactions</span>
<div class="flex flex-wrap gap-1">
<For each={policyRedactions()}>
{(label) => (
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[10px]">
{label}
</span>
)}
</For>
</div>
</div>
</Show>
<Show when={governanceSummary()}>
<div class="flex flex-col gap-1">
<span class="text-muted">AI-Safe Summary</span>
<div class="rounded border border-border bg-surface-hover px-2 py-1.5 text-[10px] text-base-content">
{governanceSummary()}
</div>
</div>
</Show>
</div>
</div>
</Show>
</div>
</Show>
</div>
</Show>
<Show when={discoveryConfig()}>
{(config) => (
<div class="mt-3">
@@ -194,8 +194,9 @@ describe('ResourceDetailDrawer change history section', () => {
expect(screen.getAllByText('Pulse diff 2')).toHaveLength(1);
expect(screen.getAllByText('Docker adapter 2')).toHaveLength(1);
expect(screen.getAllByText('Proxmox adapter 1')).toHaveLength(1);
expect(screen.getByText('Storage 1 alias')).toBeInTheDocument();
expect(screen.getByText('VM Child')).toBeInTheDocument();
expect(screen.getByText('Investigation context')).toBeInTheDocument();
expect(screen.queryByText('Storage 1 alias')).toBeNull();
expect(screen.queryByText('VM Child')).toBeNull();
expect(screen.queryByText('Capabilities 1')).toBeNull();
expect(screen.queryByText('Relationships 1')).toBeNull();
});
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render, waitFor } from '@solidjs/testing-library';
import { fireEvent, render, waitFor } from '@solidjs/testing-library';
import type { Resource } from '@/types/resource';
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
@@ -291,20 +291,24 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
aiSafeSummary: 'restricted host summary safe for remote AI consumption',
});
const { getAllByText, getByText, queryByText } = render(() => (
const { getAllByText, getByRole, getByText, queryByText } = render(() => (
<ResourceDetailDrawer resource={resource} />
));
expect(getByText('Investigation context')).toBeInTheDocument();
expect(queryByText('Data Governance')).toBeNull();
expect(queryByText('AI-Safe Summary')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show context' }));
expect(getByText('Data Governance')).toBeInTheDocument();
expect(getByText('Redactions')).toBeInTheDocument();
expect(getByText('AI-Safe Summary')).toBeInTheDocument();
expect(getAllByText('Restricted').length).toBeGreaterThan(0);
expect(getAllByText('Local Only').length).toBeGreaterThan(0);
expect(getByText('Hostname')).toBeInTheDocument();
expect(getByText('IP Address')).toBeInTheDocument();
expect(
getAllByText('restricted host summary safe for remote AI consumption').length,
).toBeGreaterThan(0);
expect(getByText('Data Governance')).toBeInTheDocument();
expect(getByText('Redactions')).toBeInTheDocument();
expect(getByText('Hostname')).toBeInTheDocument();
expect(getByText('IP Address')).toBeInTheDocument();
expect(getByText('AI-Safe Summary')).toBeInTheDocument();
expect(queryByText('Sensitive Host')).toBeNull();
expect(queryByText('sensitive-host')).toBeNull();
});
@@ -323,23 +327,33 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
},
});
const { getAllByText, getByText } = render(() => <ResourceDetailDrawer resource={resource} />);
const { getAllByText, getByRole, getByText, queryByText } = render(() => (
<ResourceDetailDrawer resource={resource} />
));
expect(getByText('Investigation context')).toBeInTheDocument();
expect(queryByText('AI-Safe Summary')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show context' }));
expect(getByText('AI-Safe Summary')).toBeInTheDocument();
expect(getAllByText('redacted by policy').length).toBeGreaterThan(1);
});
it('surfaces canonical AI intelligence for the resource overview', async () => {
const resource = baseResource({});
const { getByRole, getByText } = render(() => <ResourceDetailDrawer resource={resource} />);
const { getByRole, getByText, queryByText } = render(() => (
<ResourceDetailDrawer resource={resource} />
));
await waitFor(() => {
expect(getByText('Investigation context')).toBeInTheDocument();
});
expect(queryByText('AI Intelligence')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show context' }));
await waitFor(() => {
expect(getByText('AI Intelligence')).toBeInTheDocument();
});
expect(getByText(/A · 92\/100/)).toBeInTheDocument();
expect(getByText('Recent Changes')).toBeInTheDocument();
expect(getByText('Recent Changes').parentElement).toHaveTextContent('1');
expect(getByText('AI health A · 92/100 · 1 correlation')).toBeInTheDocument();
await waitFor(() => {
expect(getByText('Correlation context')).toBeInTheDocument();
});