Compress service panel detail density

This commit is contained in:
rcourtman 2026-03-19 22:49:38 +00:00
parent 80249a91d1
commit 981d85cb2e
4 changed files with 363 additions and 218 deletions

View file

@ -280,6 +280,10 @@ Type-specific Docker, PBS, and PMG operational panels now also live inside a
collapsed `Service details` support block, so lane-specific controls and
breakdowns stay available without displacing the common runtime and identity
hierarchy on first read.
When `Service details` is expanded, each service card remains summary-first and
pushes heavier breakdowns or update controls behind one more service-local
reveal, so the opened state still scans as current state before deeper
operations.
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

View file

@ -145,6 +145,9 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false);
const [showHostDetails, setShowHostDetails] = createSignal(false);
const [showServiceDetails, setShowServiceDetails] = createSignal(false);
const [showDockerUpdateControls, setShowDockerUpdateControls] = createSignal(false);
const [showPbsJobDetail, setShowPbsJobDetail] = createSignal(false);
const [showPmgMailFlowDetail, setShowPmgMailFlowDetail] = createSignal(false);
const displayName = createMemo(() => getPreferredResourceDisplayName(props.resource));
const kubernetesClusterName = createMemo(() =>
@ -622,6 +625,16 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
return null;
});
const pbsServiceSummary = createMemo(() => {
const pbs = pbsData();
if (!pbs) return null;
return `${formatInteger(pbs.datastoreCount)} datastores · ${formatInteger(pbsJobTotal())} jobs`;
});
const pmgServiceSummary = createMemo(() => {
const pmg = pmgData();
if (!pmg) return null;
return `${formatInteger(pmg.nodeCount)} node${pmg.nodeCount === 1 ? '' : 's'} · ${formatInteger(pmg.queueTotal)} queue total · ${formatInteger(pmgQueueBacklog())} backlog`;
});
const workloadsHref = createMemo(() => buildWorkloadsHref(props.resource));
const headerIdentity = createMemo(() => getPrimaryResourceIdentity(props.resource));
const relatedLinks = createMemo(() => {
@ -1128,139 +1141,151 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
</div>
</Show>
<Show when={dockerHostCommand()?.type || dockerHostCommand()?.status}>
<div class="rounded border border-sky-200 bg-surface px-2 py-1.5 text-[10px] dark:border-sky-700">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Command</span>
<span class="font-medium text-base-content">
{formatIdentifierLabel(dockerHostCommand()?.type, {
fallback: 'command',
})}
</span>
</div>
<div class="mt-1 flex items-center justify-between gap-2">
<span class="text-muted">Status</span>
<span
class={`font-medium ${dockerHostCommandActive() ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`}
>
{formatIdentifierLabel(dockerHostCommand()?.status, {
fallback: 'unknown',
})}
</span>
</div>
<Show when={dockerHostCommand()?.message}>
<div class="mt-1 text-muted truncate" title={dockerHostCommand()?.message}>
{dockerHostCommand()?.message}
<Show when={showDockerUpdateControls()}>
<div class="space-y-1.5 border-t border-sky-200 pt-2 dark:border-sky-700">
<Show when={dockerHostCommand()?.type || dockerHostCommand()?.status}>
<div class="rounded border border-sky-200 bg-surface px-2 py-1.5 text-[10px] dark:border-sky-700">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Command</span>
<span class="font-medium text-base-content">
{formatIdentifierLabel(dockerHostCommand()?.type, {
fallback: 'command',
})}
</span>
</div>
<div class="mt-1 flex items-center justify-between gap-2">
<span class="text-muted">Status</span>
<span
class={`font-medium ${dockerHostCommandActive() ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`}
>
{formatIdentifierLabel(dockerHostCommand()?.status, {
fallback: 'unknown',
})}
</span>
</div>
<Show when={dockerHostCommand()?.message}>
<div class="mt-1 text-muted truncate" title={dockerHostCommand()?.message}>
{dockerHostCommand()?.message}
</div>
</Show>
<Show when={dockerHostCommand()?.failureReason}>
<div
class="mt-1 text-red-700 dark:text-red-300 truncate"
title={dockerHostCommand()?.failureReason}
>
{dockerHostCommand()?.failureReason}
</div>
</Show>
</div>
</Show>
<Show when={dockerHostCommand()?.failureReason}>
<div
class="mt-1 text-red-700 dark:text-red-300 truncate"
title={dockerHostCommand()?.failureReason}
>
{dockerHostCommand()?.failureReason}
<Show when={dockerActionError()}>
<div class="rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[10px] text-red-700 dark:border-red-700 dark:bg-red-900 dark:text-red-200">
{dockerActionError()}
</div>
</Show>
<Show when={dockerActionNote()}>
<div class="rounded border border-sky-200 bg-surface px-2 py-1.5 text-[10px] text-base-content dark:border-sky-700">
{dockerActionNote()}
</div>
</Show>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={
dockerActionBusy() ||
dockerUpdateActionsLoading() ||
dockerHostCommandActive() ||
dockerHostSourceId() === null
}
onClick={async () => {
setDockerActionError('');
setDockerActionNote('');
setConfirmUpdateAll(false);
const hostId = dockerHostSourceId();
if (!hostId) return;
try {
setDockerActionBusy(true);
await MonitoringAPI.checkDockerUpdates(hostId);
setDockerActionNote(
'Update check queued. Results will refresh on the next agent report.',
);
} catch (err) {
setDockerActionError(
(err as Error)?.message || 'Failed to queue update check',
);
} finally {
setDockerActionBusy(false);
}
}}
class="rounded-md border border-border bg-surface px-2.5 py-1 text-[11px] font-semibold text-base-content hover:bg-surface-hover disabled:opacity-60"
title={dockerUpdateActionsLoading() ? 'Loading server settings...' : undefined}
>
Check Updates
</button>
<button
type="button"
disabled={
dockerActionBusy() ||
dockerUpdateActionsLoading() ||
dockerUpdateActionsDisabled() ||
dockerHostCommandActive() ||
dockerHostSourceId() === null ||
dockerUpdatesAvailable() <= 0
}
onClick={async () => {
setDockerActionError('');
setDockerActionNote('');
const hostId = dockerHostSourceId();
if (!hostId) return;
if (!confirmUpdateAll()) {
setConfirmUpdateAll(true);
setDockerActionNote(
`Click again to confirm updating ${dockerUpdatesAvailable()} container(s).`,
);
return;
}
try {
setDockerActionBusy(true);
await MonitoringAPI.updateAllDockerContainers(hostId);
setDockerActionNote(
'Batch update queued. Progress will appear as the agent reports back.',
);
} catch (err) {
setDockerActionError(
(err as Error)?.message || 'Failed to queue batch update',
);
} finally {
setDockerActionBusy(false);
setConfirmUpdateAll(false);
}
}}
class="rounded-md border border-sky-200 bg-sky-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-sky-700 disabled:opacity-60 disabled:hover:bg-sky-600 dark:border-sky-700 dark:bg-sky-600 dark:hover:bg-sky-500 dark:disabled:hover:bg-sky-600"
title={
dockerUpdateActionsDisabled()
? 'Docker updates are disabled by server configuration.'
: undefined
}
>
{confirmUpdateAll()
? 'Confirm Update All'
: `Update All${dockerUpdatesAvailable() > 0 ? ` (${dockerUpdatesAvailable()})` : ''}`}
</button>
</div>
</div>
</Show>
<Show when={dockerActionError()}>
<div class="rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[10px] text-red-700 dark:border-red-700 dark:bg-red-900 dark:text-red-200">
{dockerActionError()}
</div>
</Show>
<Show when={dockerActionNote()}>
<div class="rounded border border-sky-200 bg-surface px-2 py-1.5 text-[10px] text-base-content dark:border-sky-700">
{dockerActionNote()}
</div>
</Show>
<div class="flex flex-wrap items-center gap-2 pt-1">
<button
type="button"
disabled={
dockerActionBusy() ||
dockerUpdateActionsLoading() ||
dockerHostCommandActive() ||
dockerHostSourceId() === null
}
onClick={async () => {
setDockerActionError('');
setDockerActionNote('');
setConfirmUpdateAll(false);
const hostId = dockerHostSourceId();
if (!hostId) return;
try {
setDockerActionBusy(true);
await MonitoringAPI.checkDockerUpdates(hostId);
setDockerActionNote(
'Update check queued. Results will refresh on the next agent report.',
);
} catch (err) {
setDockerActionError(
(err as Error)?.message || 'Failed to queue update check',
);
} finally {
setDockerActionBusy(false);
}
}}
class="rounded-md border border-border bg-surface px-2.5 py-1 text-[11px] font-semibold text-base-content hover:bg-surface-hover disabled:opacity-60"
title={dockerUpdateActionsLoading() ? 'Loading server settings...' : undefined}
>
Check Updates
</button>
<button
type="button"
disabled={
dockerActionBusy() ||
dockerUpdateActionsLoading() ||
dockerUpdateActionsDisabled() ||
dockerHostCommandActive() ||
dockerHostSourceId() === null ||
dockerUpdatesAvailable() <= 0
}
onClick={async () => {
setDockerActionError('');
setDockerActionNote('');
const hostId = dockerHostSourceId();
if (!hostId) return;
if (!confirmUpdateAll()) {
setConfirmUpdateAll(true);
setDockerActionNote(
`Click again to confirm updating ${dockerUpdatesAvailable()} container(s).`,
);
return;
}
try {
setDockerActionBusy(true);
await MonitoringAPI.updateAllDockerContainers(hostId);
setDockerActionNote(
'Batch update queued. Progress will appear as the agent reports back.',
);
} catch (err) {
setDockerActionError(
(err as Error)?.message || 'Failed to queue batch update',
);
} finally {
setDockerActionBusy(false);
setConfirmUpdateAll(false);
}
}}
class="rounded-md border border-sky-200 bg-sky-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-sky-700 disabled:opacity-60 disabled:hover:bg-sky-600 dark:border-sky-700 dark:bg-sky-600 dark:hover:bg-sky-500 dark:disabled:hover:bg-sky-600"
title={
dockerUpdateActionsDisabled()
? 'Docker updates are disabled by server configuration.'
: undefined
}
>
{confirmUpdateAll()
? 'Confirm Update All'
: `Update All${dockerUpdatesAvailable() > 0 ? ` (${dockerUpdatesAvailable()})` : ''}`}
</button>
</div>
<button
type="button"
onClick={() => setShowDockerUpdateControls((value) => !value)}
class="inline-flex items-center rounded-md border border-sky-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-sky-700 transition-colors hover:bg-base dark:border-sky-700 dark:text-sky-300"
>
{showDockerUpdateControls() ? 'Hide update controls' : 'Show update controls'}
</button>
</div>
</div>
</Show>
@ -1305,38 +1330,57 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
</span>
</div>
</Show>
<div class="grid grid-cols-2 gap-2 pt-1">
<div class="rounded border border-indigo-200 bg-surface px-2 py-1.5 dark:border-indigo-700">
<div class="text-[10px] text-muted">Datastores</div>
<div class="text-sm font-semibold text-base-content">
{formatInteger(pbs().datastoreCount)}
<Show when={pbsServiceSummary()}>
<div class="rounded border border-indigo-200 bg-surface px-2 py-1.5 text-[10px] dark:border-indigo-700">
<div class="text-muted">Backup summary</div>
<div class="mt-1 font-medium text-base-content">
{pbsServiceSummary()}
</div>
</div>
<div class="rounded border border-indigo-200 bg-surface px-2 py-1.5 dark:border-indigo-700">
<div class="text-[10px] text-muted">Total Jobs</div>
<div class="text-sm font-semibold text-base-content">
{formatInteger(pbsJobTotal())}
</Show>
<Show when={showPbsJobDetail()}>
<div class="space-y-1.5 border-t border-indigo-200 pt-2 dark:border-indigo-700">
<div class="grid grid-cols-2 gap-2">
<div class="rounded border border-indigo-200 bg-surface px-2 py-1.5 dark:border-indigo-700">
<div class="text-[10px] text-muted">Datastores</div>
<div class="text-sm font-semibold text-base-content">
{formatInteger(pbs().datastoreCount)}
</div>
</div>
<div class="rounded border border-indigo-200 bg-surface px-2 py-1.5 dark:border-indigo-700">
<div class="text-[10px] text-muted">Total Jobs</div>
<div class="text-sm font-semibold text-base-content">
{formatInteger(pbsJobTotal())}
</div>
</div>
</div>
<details class="rounded border border-indigo-200 bg-surface px-2 py-1.5 dark:border-indigo-700">
<summary class="flex cursor-pointer list-none items-center justify-between text-[10px] font-medium text-muted">
<span>Job breakdown</span>
<span class="text-muted">{pbsVisibleJobBreakdown().length} types</span>
</summary>
<div class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 border-t border-indigo-200 pt-2 text-[10px] dark:border-indigo-700">
<For each={pbsVisibleJobBreakdown()}>
{(entry) => (
<span class="text-muted">
{entry.label}:{' '}
<span class="font-medium text-base-content">
{formatInteger(entry.value)}
</span>
</span>
)}
</For>
</div>
</details>
</div>
</div>
<details class="rounded border border-indigo-200 bg-surface px-2 py-1.5 dark:border-indigo-700">
<summary class="flex cursor-pointer list-none items-center justify-between text-[10px] font-medium text-muted">
<span>Job breakdown</span>
<span class="text-muted">{pbsVisibleJobBreakdown().length} types</span>
</summary>
<div class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 border-t border-indigo-200 pt-2 text-[10px] dark:border-indigo-700">
<For each={pbsVisibleJobBreakdown()}>
{(entry) => (
<span class="text-muted">
{entry.label}:{' '}
<span class="font-medium text-base-content">
{formatInteger(entry.value)}
</span>
</span>
)}
</For>
</div>
</details>
</Show>
<button
type="button"
onClick={() => setShowPbsJobDetail((value) => !value)}
class="inline-flex items-center rounded-md border border-indigo-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-indigo-700 transition-colors hover:bg-base dark:border-indigo-700 dark:text-indigo-300"
>
{showPbsJobDetail() ? 'Hide job detail' : 'Show job detail'}
</button>
</div>
</div>
);
@ -1383,74 +1427,93 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
</span>
</div>
</Show>
<div class="grid grid-cols-3 gap-2 pt-1">
<div class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<div class="text-[10px] text-muted">Nodes</div>
<div class="text-sm font-semibold text-base-content">
{formatInteger(pmg().nodeCount)}
<Show when={pmgServiceSummary()}>
<div class="rounded border border-rose-200 bg-surface px-2 py-1.5 text-[10px] dark:border-rose-700">
<div class="text-muted">Mail flow summary</div>
<div class="mt-1 font-medium text-base-content">
{pmgServiceSummary()}
</div>
</div>
<div class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<div class="text-[10px] text-muted">Queue Total</div>
<div
class={`text-sm font-semibold ${pmgQueueBacklog() > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`}
>
{formatInteger(pmg().queueTotal)}
</Show>
<Show when={showPmgMailFlowDetail()}>
<div class="space-y-1.5 border-t border-rose-200 pt-2 dark:border-rose-700">
<div class="grid grid-cols-3 gap-2">
<div class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<div class="text-[10px] text-muted">Nodes</div>
<div class="text-sm font-semibold text-base-content">
{formatInteger(pmg().nodeCount)}
</div>
</div>
<div class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<div class="text-[10px] text-muted">Queue Total</div>
<div
class={`text-sm font-semibold ${pmgQueueBacklog() > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`}
>
{formatInteger(pmg().queueTotal)}
</div>
</div>
<div class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<div class="text-[10px] text-muted">Backlog</div>
<div
class={`text-sm font-semibold ${pmgQueueBacklog() > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`}
>
{formatInteger(pmgQueueBacklog())}
</div>
</div>
</div>
<details class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<summary class="flex cursor-pointer list-none items-center justify-between text-[10px] font-medium text-muted">
<span>Queue breakdown</span>
<span class="text-muted">{pmgVisibleQueueBreakdown().length} signals</span>
</summary>
<div class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<For each={pmgVisibleQueueBreakdown()}>
{(entry) => (
<span class="text-muted">
{entry.label}:{' '}
<span
class={`font-medium ${entry.warn ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`}
>
{formatInteger(entry.value)}
</span>
</span>
)}
</For>
</div>
</details>
<details class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<summary class="flex cursor-pointer list-none items-center justify-between text-[10px] font-medium text-muted">
<span>Mail processing</span>
<span class="text-muted">{pmgVisibleMailBreakdown().length} signals</span>
</summary>
<div class="mt-2 grid grid-cols-3 gap-x-3 gap-y-1 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<For each={pmgVisibleMailBreakdown()}>
{(entry) => (
<span class="text-muted">
{entry.label}:{' '}
<span class="font-medium text-base-content">
{formatInteger(entry.value)}
</span>
</span>
)}
</For>
</div>
<Show when={pmgUpdatedRelative()}>
<div class="mt-2 flex items-center justify-between gap-2 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<span class="text-muted">Updated</span>
<span class="font-medium text-base-content">{pmgUpdatedRelative()}</span>
</div>
</Show>
</details>
</div>
<div class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<div class="text-[10px] text-muted">Backlog</div>
<div
class={`text-sm font-semibold ${pmgQueueBacklog() > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`}
>
{formatInteger(pmgQueueBacklog())}
</div>
</div>
</div>
<details class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<summary class="flex cursor-pointer list-none items-center justify-between text-[10px] font-medium text-muted">
<span>Queue breakdown</span>
<span class="text-muted">{pmgVisibleQueueBreakdown().length} signals</span>
</summary>
<div class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<For each={pmgVisibleQueueBreakdown()}>
{(entry) => (
<span class="text-muted">
{entry.label}:{' '}
<span
class={`font-medium ${entry.warn ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`}
>
{formatInteger(entry.value)}
</span>
</span>
)}
</For>
</div>
</details>
<details class="rounded border border-rose-200 bg-surface px-2 py-1.5 dark:border-rose-700">
<summary class="flex cursor-pointer list-none items-center justify-between text-[10px] font-medium text-muted">
<span>Mail processing</span>
<span class="text-muted">{pmgVisibleMailBreakdown().length} signals</span>
</summary>
<div class="mt-2 grid grid-cols-3 gap-x-3 gap-y-1 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<For each={pmgVisibleMailBreakdown()}>
{(entry) => (
<span class="text-muted">
{entry.label}:{' '}
<span class="font-medium text-base-content">
{formatInteger(entry.value)}
</span>
</span>
)}
</For>
</div>
<Show when={pmgUpdatedRelative()}>
<div class="mt-2 flex items-center justify-between gap-2 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<span class="text-muted">Updated</span>
<span class="font-medium text-base-content">{pmgUpdatedRelative()}</span>
</div>
</Show>
</details>
</Show>
<button
type="button"
onClick={() => setShowPmgMailFlowDetail((value) => !value)}
class="inline-flex items-center rounded-md border border-rose-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-rose-700 transition-colors hover:bg-base dark:border-rose-700 dark:text-rose-300"
>
{showPmgMailFlowDetail() ? 'Hide mail flow detail' : 'Show mail flow detail'}
</button>
</div>
</div>
);

View file

@ -324,6 +324,42 @@ describe('ResourceDetailDrawer change history section', () => {
expect(panel.queryByText('Runs on')).toBeNull();
});
it('keeps service details summary-first until the service-local reveal is opened', () => {
facetBundleMock.getFacetBundle.mockResolvedValueOnce({
capabilities: [],
relationships: [],
recentChanges: [],
});
const resource = baseResource({
id: 'pbs-1',
type: 'pbs',
name: 'pbs-main',
displayName: 'PBS Main',
platformId: 'pbs-main',
platformType: 'proxmox-pbs',
platformData: {
sources: ['pbs'],
pbs: {
hostname: 'pbs-main.local',
connectionHealth: 'online',
datastoreCount: 2,
backupJobCount: 3,
},
},
});
render(() => <ResourceDetailDrawer resource={resource} />);
expect(screen.getByText('Service details')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show service details' }));
expect(screen.getByText('PBS Service')).toBeInTheDocument();
expect(screen.getByText('Backup summary')).toBeInTheDocument();
expect(screen.queryByText('Job breakdown')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show job detail' }));
expect(screen.getByText('Job breakdown')).toBeInTheDocument();
});
it('filters timeline entries by kind and source type', async () => {
const unfilteredFacetBundle = {
capabilities: [

View file

@ -76,6 +76,9 @@ describe('ResourceDetailDrawer service cards', () => {
fireEvent.click(getByRole('button', { name: 'Show service details' }));
expect(getByText('PBS Service')).toBeInTheDocument();
expect(getAllByText('pbs-main.local').length).toBeGreaterThan(0);
expect(getByText('Backup summary')).toBeInTheDocument();
expect(queryByText('Job breakdown')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show job detail' }));
expect(getByText('Datastores')).toBeInTheDocument();
expect(getByText('Total Jobs')).toBeInTheDocument();
expect(getByText('Job breakdown')).toBeInTheDocument();
@ -121,6 +124,10 @@ describe('ResourceDetailDrawer service cards', () => {
expect(getByText('Mail Gateway')).toBeInTheDocument();
});
expect(getAllByText('pmg-main.local').length).toBeGreaterThan(0);
expect(getByText('Mail flow summary')).toBeInTheDocument();
expect(queryByText('Queue breakdown')).toBeNull();
expect(queryByText('Mail processing')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show mail flow detail' }));
expect(getByText('Queue Total')).toBeInTheDocument();
expect(getByText('Backlog')).toBeInTheDocument();
expect(getByText('Queue breakdown')).toBeInTheDocument();
@ -130,4 +137,39 @@ describe('ResourceDetailDrawer service cards', () => {
'/alerts/thresholds/mail-gateway',
);
});
it('keeps docker update controls behind a secondary reveal', () => {
const resource = baseResource({
id: 'docker-host-1',
type: 'docker-host',
name: 'docker-main',
displayName: 'Docker Main',
platformId: 'docker-main',
platformType: 'docker',
sourceType: 'agent',
platformData: {
sources: ['docker', 'agent'],
docker: {
hostSourceId: 'docker-host-1',
hostname: 'docker-main.local',
runtime: 'Docker Engine 28.0',
containerCount: 18,
updatesAvailableCount: 4,
},
},
});
const { getByText, getByRole, queryByText } = render(() => (
<ResourceDetailDrawer resource={resource} />
));
expect(getByText('Service details')).toBeInTheDocument();
expect(getByText('18 containers · 4 updates')).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'Show service details' }));
expect(getByText('Container Updates')).toBeInTheDocument();
expect(queryByText('Check Updates')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show update controls' }));
expect(getByText('Check Updates')).toBeInTheDocument();
expect(getByText('Update All (4)')).toBeInTheDocument();
});
});