mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-23 07:53:36 +00:00
Show Docker container update status in rows
This commit is contained in:
parent
e3cce1efba
commit
08e855483f
6 changed files with 201 additions and 106 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue