diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 8690b4ef0..e76c26595 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -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', () => { diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 6811c68d2..2baaba18c 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -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)); diff --git a/frontend-modern/src/components/shared/ContainerUpdateBadge.tsx b/frontend-modern/src/components/shared/ContainerUpdateBadge.tsx index 089c3e9d3..6488f9e79 100644 --- a/frontend-modern/src/components/shared/ContainerUpdateBadge.tsx +++ b/frontend-modern/src/components/shared/ContainerUpdateBadge.tsx @@ -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 = (props) => { return ( - - - { - const rect = e.currentTarget.getBoundingClientRect(); - showTooltip(getContainerUpdateErrorTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, { - align: 'center', - direction: 'up', - }); - }} - onMouseLeave={() => hideTooltip()} - > - - - Check failed - - - - } - > - { - const rect = e.currentTarget.getBoundingClientRect(); - showTooltip(getContainerUpdateBadgeTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, { - align: 'center', - direction: 'up', - }); - }} - onMouseLeave={() => hideTooltip()} - > - - - Update - - - + + + + { + const rect = e.currentTarget.getBoundingClientRect(); + showTooltip(getContainerUpdateBadgeTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, { + align: 'center', + direction: 'up', + }); + }} + onMouseLeave={() => hideTooltip()} + > + + + Update + + + + + { + const rect = e.currentTarget.getBoundingClientRect(); + showTooltip(getContainerUpdateErrorTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, { + align: 'center', + direction: 'up', + }); + }} + onMouseLeave={() => hideTooltip()} + > + + {props.compact ? 'Failed' : 'Check failed'} + + + + { + const rect = e.currentTarget.getBoundingClientRect(); + showTooltip(getContainerUpdateCurrentTooltip(props.updateStatus), rect.left + rect.width / 2, rect.top, { + align: 'center', + direction: 'up', + }); + }} + onMouseLeave={() => hideTooltip()} + > + + + + Current + + + ); }; @@ -151,70 +175,74 @@ export const UpdateIcon: Component = (props) => { */ export const UpdateButton: Component = (props) => { const state = useContainerUpdateButtonState(props); + const shouldRenderReadOnlyStatus = () => + state.currentState() === 'idle' && + (hasContainerUpdateError(props.updateStatus) || + hasContainerUpdateCurrent(props.updateStatus) || + (state.settingsLoaded() && state.shouldHideButton())); return ( - - - - - -
- - + - -
+ + + + + } + > +
); diff --git a/frontend-modern/src/components/shared/__tests__/ContainerUpdateBadge.test.tsx b/frontend-modern/src/components/shared/__tests__/ContainerUpdateBadge.test.tsx index ac2e85119..827f6bc5c 100644 --- a/frontend-modern/src/components/shared/__tests__/ContainerUpdateBadge.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/ContainerUpdateBadge.test.tsx @@ -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(() => ( + + )); + + expect(screen.getByText('Current')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders a visible failed check state without exposing an update button', () => { + render(() => ( + + )); + + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/components/shared/containerUpdateBadgeModel.ts b/frontend-modern/src/components/shared/containerUpdateBadgeModel.ts index 01127a885..57f6ce583 100644 --- a/frontend-modern/src/components/shared/containerUpdateBadgeModel.ts +++ b/frontend-modern/src/components/shared/containerUpdateBadgeModel.ts @@ -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 { diff --git a/frontend-modern/src/components/shared/useContainerUpdateButtonState.ts b/frontend-modern/src/components/shared/useContainerUpdateButtonState.ts index 7ded35a51..80d1f6050 100644 --- a/frontend-modern/src/components/shared/useContainerUpdateButtonState.ts +++ b/frontend-modern/src/components/shared/useContainerUpdateButtonState.ts @@ -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()