Show Docker container update status in rows

This commit is contained in:
rcourtman 2026-05-18 11:28:00 +01:00
parent e3cce1efba
commit 08e855483f
6 changed files with 201 additions and 106 deletions

View file

@ -800,7 +800,7 @@ describe('GuestRow', () => {
describe('GUEST_COLUMNS', () => {
it('has the expected number of columns', () => {
// name, type, info, vmid, cpu, memory, disk, ip, uptime, node,
// image, namespace, context, backup, tags, update, os, netIo, diskIo
// image, namespace, context, backup, tags, os, netIo, diskIo, update
expect(GUEST_COLUMNS.length).toBe(19);
});
@ -810,7 +810,13 @@ describe('GUEST_COLUMNS', () => {
it('does not expose a trailing link column', () => {
expect(GUEST_COLUMNS.map((column) => column.id)).not.toContain('link');
expect(GUEST_COLUMNS[GUEST_COLUMNS.length - 1].id).toBe('diskIo');
expect(GUEST_COLUMNS[GUEST_COLUMNS.length - 1].id).toBe('update');
});
it('keeps Docker I/O and update headers aligned with the rendered row cells', () => {
const columnIds = GUEST_COLUMNS.map((column) => column.id);
const dockerTailStart = columnIds.indexOf('netIo');
expect(columnIds.slice(dockerTailStart)).toEqual(['netIo', 'diskIo', 'update']);
});
it('marks toggleable columns correctly', () => {

View file

@ -305,7 +305,6 @@ export const GUEST_COLUMNS: ColumnDef[] = [
width: '60px',
toggleable: true,
},
{ id: 'update', label: 'Update', width: '60px', toggleable: true },
{ id: 'os', label: 'OS', width: '45px', toggleable: true },
{
id: 'netIo',
@ -323,6 +322,7 @@ export const GUEST_COLUMNS: ColumnDef[] = [
toggleable: true,
sortKey: 'diskIo',
},
{ id: 'update', label: 'Update', width: '60px', toggleable: true },
];
const GUEST_COLUMN_BY_ID = new Map(GUEST_COLUMNS.map((column) => [column.id, column] as const));

View file

@ -2,10 +2,12 @@ import { Component, Match, Show, Switch } from 'solid-js';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import {
getContainerUpdateBadgeTooltip,
getContainerUpdateCurrentTooltip,
getContainerUpdateErrorTooltip,
getUpdateButtonClass,
getUpdateIconTooltip,
hasContainerUpdate,
hasContainerUpdateCurrent,
hasContainerUpdateError,
type ContainerUpdateBadgeProps,
type UpdateButtonProps,
@ -64,52 +66,74 @@ const XIcon: Component<{ class?: string }> = (props) => (
);
/**
* ContainerUpdateBadge displays a visual indicator when a container image has an update available.
* Uses a blue color scheme to differentiate from health/status badges.
* ContainerUpdateBadge displays Docker image update check results.
* Update-available states use a blue color scheme to differentiate from health/status badges.
*/
export const ContainerUpdateBadge: Component<ContainerUpdateBadgeProps> = (props) => {
return (
<Show when={hasContainerUpdate(props.updateStatus) || hasContainerUpdateError(props.updateStatus)}>
<Show
when={hasContainerUpdate(props.updateStatus)}
fallback={
<Show when={hasContainerUpdateError(props.updateStatus)}>
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium bg-surface-alt text-muted cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(getContainerUpdateErrorTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
>
<ErrorIndicatorIcon class="w-3 h-3" />
<Show when={!props.compact}>
<span>Check failed</span>
</Show>
</span>
</Show>
}
>
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(getContainerUpdateBadgeTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
>
<UpdateArrowIcon class="w-3 h-3" />
<Show when={!props.compact}>
<span>Update</span>
</Show>
</span>
</Show>
<Show
when={
hasContainerUpdate(props.updateStatus) ||
hasContainerUpdateError(props.updateStatus) ||
(props.showCurrent && hasContainerUpdateCurrent(props.updateStatus))
}
>
<Switch>
<Match when={hasContainerUpdate(props.updateStatus)}>
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(getContainerUpdateBadgeTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
>
<UpdateArrowIcon class="w-3 h-3" />
<Show when={!props.compact}>
<span>Update</span>
</Show>
</span>
</Match>
<Match when={hasContainerUpdateError(props.updateStatus)}>
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium bg-surface-alt text-muted cursor-help"
aria-label={getContainerUpdateErrorTooltip(props.updateStatus)}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(getContainerUpdateErrorTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
>
<ErrorIndicatorIcon class="w-3 h-3" />
<span>{props.compact ? 'Failed' : 'Check failed'}</span>
</span>
</Match>
<Match when={props.showCurrent && hasContainerUpdateCurrent(props.updateStatus)}>
<span
class="inline-flex items-center justify-center gap-1 rounded-full bg-surface-alt px-1.5 py-0.5 text-[11px] font-medium text-muted cursor-help"
aria-label={getContainerUpdateCurrentTooltip(props.updateStatus)}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(getContainerUpdateCurrentTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
>
<Show when={!props.compact}>
<CheckIcon class="w-3 h-3" />
</Show>
<span>Current</span>
</span>
</Match>
</Switch>
</Show>
);
};
@ -151,70 +175,74 @@ export const UpdateIcon: Component<UpdateIconProps> = (props) => {
*/
export const UpdateButton: Component<UpdateButtonProps> = (props) => {
const state = useContainerUpdateButtonState(props);
const shouldRenderReadOnlyStatus = () =>
state.currentState() === 'idle' &&
(hasContainerUpdateError(props.updateStatus) ||
hasContainerUpdateCurrent(props.updateStatus) ||
(state.settingsLoaded() && state.shouldHideButton()));
return (
<Show when={state.hasUpdate()}>
<Show when={state.settingsLoaded() && state.shouldHideButton()}>
<ContainerUpdateBadge updateStatus={props.updateStatus} compact={props.compact} />
</Show>
<Show when={!state.settingsLoaded() || !state.shouldHideButton()}>
<div class="inline-flex items-center gap-1" data-prevent-toggle>
<button
type="button"
class={getUpdateButtonClass(state.currentState())}
onClick={state.handleClick}
onMouseDown={(e) => {
e.stopPropagation();
}}
disabled={state.isButtonDisabled()}
aria-label={state.buttonTooltip() || state.buttonLabel()}
data-prevent-toggle
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(state.buttonTooltip(), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
>
<Show when={!state.settingsLoaded()}>
<UpdateArrowIcon class="w-3 h-3 animate-pulse opacity-50" />
</Show>
<Show when={state.settingsLoaded()}>
<Switch>
<Match when={state.currentState() === 'updating'}>
<SpinnerIcon class="w-3 h-3 animate-spin" />
</Match>
<Match when={state.currentState() === 'success'}>
<CheckIcon class="w-3 h-3" />
</Match>
<Match when={state.currentState() === 'error'}>
<XIcon class="w-3 h-3" />
</Match>
<Match when={state.currentState() === 'idle' || state.currentState() === 'confirming'}>
<UpdateArrowIcon class="w-3 h-3" />
</Match>
</Switch>
</Show>
<Show when={!props.compact}>
<span class={!state.settingsLoaded() ? 'opacity-50' : ''}>
{state.buttonLabel()}
</span>
</Show>
</button>
<Show when={state.settingsLoaded() && state.currentState() === 'confirming'}>
<Show
when={shouldRenderReadOnlyStatus()}
fallback={
<div class="inline-flex items-center gap-1" data-prevent-toggle>
<button
type="button"
class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-surface-alt text-muted hover:bg-surface-hover transition-colors"
onClick={state.handleCancel}
title="Cancel"
class={getUpdateButtonClass(state.currentState())}
onClick={state.handleClick}
onMouseDown={(e) => {
e.stopPropagation();
}}
disabled={state.isButtonDisabled()}
aria-label={state.buttonTooltip() || state.buttonLabel()}
data-prevent-toggle
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(state.buttonTooltip(), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
>
<XIcon class="w-3 h-3" />
<Show when={!state.settingsLoaded()}>
<UpdateArrowIcon class="w-3 h-3 animate-pulse opacity-50" />
</Show>
<Show when={state.settingsLoaded()}>
<Switch>
<Match when={state.currentState() === 'updating'}>
<SpinnerIcon class="w-3 h-3 animate-spin" />
</Match>
<Match when={state.currentState() === 'success'}>
<CheckIcon class="w-3 h-3" />
</Match>
<Match when={state.currentState() === 'error'}>
<XIcon class="w-3 h-3" />
</Match>
<Match when={state.currentState() === 'idle' || state.currentState() === 'confirming'}>
<UpdateArrowIcon class="w-3 h-3" />
</Match>
</Switch>
</Show>
<Show when={!props.compact}>
<span class={!state.settingsLoaded() ? 'opacity-50' : ''}>{state.buttonLabel()}</span>
</Show>
</button>
</Show>
</div>
<Show when={state.settingsLoaded() && state.currentState() === 'confirming'}>
<button
type="button"
class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-surface-alt text-muted hover:bg-surface-hover transition-colors"
onClick={state.handleCancel}
title="Cancel"
>
<XIcon class="w-3 h-3" />
</button>
</Show>
</div>
}
>
<ContainerUpdateBadge updateStatus={props.updateStatus} compact={props.compact} showCurrent={true} />
</Show>
</Show>
);

View file

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import containerUpdateBadgeSource from '@/components/shared/ContainerUpdateBadge.tsx?raw';
import containerUpdateBadgeModelSource from '@/components/shared/containerUpdateBadgeModel.ts?raw';
import containerUpdateButtonStateSource from '@/components/shared/useContainerUpdateButtonState.ts?raw';
@ -28,6 +28,8 @@ vi.mock('@/stores/systemSettings', () => ({
shouldHideDockerUpdateActions: () => false,
}));
afterEach(cleanup);
describe('ContainerUpdateBadge', () => {
it('keeps the badge on shell, runtime, and model owners', () => {
expect(containerUpdateBadgeSource).toContain('useContainerUpdateButtonState');
@ -48,6 +50,7 @@ describe('ContainerUpdateBadge', () => {
expect(containerUpdateBadgeModelSource).toContain('getUpdateButtonClass');
expect(containerUpdateBadgeModelSource).toContain('getUpdateButtonTooltip');
expect(containerUpdateBadgeModelSource).toContain('hasContainerUpdate');
expect(containerUpdateBadgeModelSource).toContain('hasContainerUpdateCurrent');
});
it('renders the error badge fallback when update detection fails', () => {
@ -81,4 +84,42 @@ describe('ContainerUpdateBadge', () => {
expect(screen.getByRole('button', { name: /update/i })).toBeInTheDocument();
});
it('renders a visible current state when the checked image is up to date', () => {
render(() => (
<UpdateButton
agentId="agent-1"
containerId="container-1"
containerName="web"
compact={true}
updateStatus={{
updateAvailable: false,
currentDigest: 'sha256:current',
lastChecked: 0,
}}
/>
));
expect(screen.getByText('Current')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('renders a visible failed check state without exposing an update button', () => {
render(() => (
<UpdateButton
agentId="agent-1"
containerId="container-1"
containerName="web"
compact={true}
updateStatus={{
updateAvailable: false,
lastChecked: 0,
error: 'request timed out',
}}
/>
));
expect(screen.getByText('Failed')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View file

@ -3,6 +3,7 @@ import type { DockerContainerUpdateStatus } from '@/types/api';
export interface ContainerUpdateBadgeProps {
updateStatus?: DockerContainerUpdateStatus;
compact?: boolean;
showCurrent?: boolean;
}
export interface UpdateIconProps {
@ -41,12 +42,25 @@ export function hasContainerUpdateError(updateStatus?: DockerContainerUpdateStat
return Boolean(updateStatus?.error);
}
export function hasContainerUpdateCurrent(updateStatus?: DockerContainerUpdateStatus): boolean {
return updateStatus?.updateAvailable === false && !hasContainerUpdateError(updateStatus);
}
export function getContainerUpdateErrorTooltip(
updateStatus?: DockerContainerUpdateStatus,
): string {
return `Update check failed: ${updateStatus?.error || 'Unknown error'}`;
}
export function getContainerUpdateCurrentTooltip(
updateStatus?: DockerContainerUpdateStatus,
): string {
if (!updateStatus?.currentDigest) return 'Image is current';
const current = getDigestPreview(updateStatus.currentDigest, 12);
return `Image is current\nDigest: ${current}...`;
}
export function getContainerUpdateBadgeTooltip(
updateStatus?: DockerContainerUpdateStatus,
): string {

View file

@ -13,6 +13,8 @@ import {
getUpdateButtonLabel,
getUpdateButtonTooltip,
hasContainerUpdate,
hasContainerUpdateCurrent,
hasContainerUpdateError,
type UpdateButtonProps,
type UpdateState,
} from './containerUpdateBadgeModel';
@ -57,7 +59,11 @@ export function useContainerUpdateButtonState(props: UpdateButtonProps) {
}
});
const hasUpdate = () => hasContainerUpdate(props.updateStatus) || currentState() !== 'idle';
const hasUpdate = () =>
hasContainerUpdate(props.updateStatus) ||
hasContainerUpdateError(props.updateStatus) ||
hasContainerUpdateCurrent(props.updateStatus) ||
currentState() !== 'idle';
const isButtonDisabled = () => currentState() === 'updating' || !settingsLoaded();
const buttonTooltip = () =>
!settingsLoaded()