mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Simplify resource drawer overview hierarchy
This commit is contained in:
@@ -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">
|
||||
|
||||
+3
-2
@@ -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();
|
||||
});
|
||||
|
||||
+27
-13
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user