fix(anatomy): name each image with a pending update in the update banner (#1575)

Show each updated image and tag transition in the Anatomy banner.

Uses the per-image data the update-preview API already returns.
This commit is contained in:
Anso
2026-07-06 02:08:02 -04:00
committed by GitHub
parent 04f3a55e5a
commit f30a65ee08
5 changed files with 112 additions and 11 deletions
+1 -1
View File
@@ -269,7 +269,7 @@ Each row maps one compose concept to the value it resolves to right now:
A footer card under the rows surfaces the first published port as a clickable **EXPOSED** link, so you can jump straight to the running app.
If an image update is available for the primary service, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows. Registry updates show the version bump (`27.1.4 → 27.1.5`), risk classification (`safe · patch`, `minor`, or `major · review required`), and an **apply** button. Build-only stacks show **Rebuild available** with a **Rebuild & Update** button instead of a version bump. Mixed stacks (registry images plus local builds) show both signals. Major bumps show a rose banner and require explicit review before applying.
If an image update is available, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows. Registry updates name each image with a pending update and show its version transition (`27.1.4 → 27.1.5`), risk classification (`safe · patch`, `minor`, or `major · review required`), and an **apply** button. Build-only stacks show **Rebuild available** with a **Rebuild & Update** button instead of a version bump. Mixed stacks (registry images plus local builds) show both signals. Major bumps show a rose banner and require explicit review before applying.
Rebuilds can take longer than a registry pull and depend on the local Dockerfile context, network access, and base-image availability. Atomic rollback restores compose and env files only; previously built image layers are not rolled back automatically.
@@ -27,6 +27,16 @@ function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
const hasBuild = buildServices.length > 0;
return {
build_services: buildServices,
images: [
{
service: 'web',
image: 'nginx:1.25',
current_tag: '1.25',
next_tag: hasUpdate ? '1.26' : null,
has_update: hasUpdate,
semver_bump: hasUpdate ? 'minor' : 'none',
},
],
summary: {
has_update: hasUpdate,
primary_image: 'nginx',
@@ -87,10 +97,49 @@ describe('StackAnatomyPanel update banner', () => {
render(panel(false, onApply));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText('nginx')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'apply' }));
expect(onApply).toHaveBeenCalledTimes(1);
});
it('names each updated image on multi-service stacks', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes({
build_services: [],
images: [
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26', has_update: true, semver_bump: 'minor' },
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.4', has_update: true, semver_bump: 'minor' },
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, semver_bump: 'none' },
],
summary: {
has_update: true,
primary_image: 'nginx:1.25',
current_tag: '1.25',
next_tag: '1.26',
semver_bump: 'minor',
update_kind: 'tag',
blocked: false,
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
},
changelog: null,
});
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText('nginx')).toBeInTheDocument();
expect(screen.getByText('redis')).toBeInTheDocument();
expect(screen.queryByText('postgres')).not.toBeInTheDocument();
});
it('shows Rebuild & Update for build-only stacks', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
+32 -9
View File
@@ -7,7 +7,7 @@ import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
import { parseAnatomy, parseEnvKeys, formatGitSource, imageName, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
import { buildServiceUrl } from '@/lib/serviceUrl';
import { StackActivityTimeline } from './stack/StackActivityTimeline';
import StackDossierPanel from './stack/StackDossierPanel';
@@ -41,6 +41,15 @@ interface StackAnatomyPanelProps {
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
type UpdateKind = 'tag' | 'digest' | 'none';
interface UpdatePreviewImage {
service: string;
image: string;
current_tag: string;
next_tag: string | null;
has_update: boolean;
semver_bump: SemverBump;
}
interface UpdatePreviewSummary {
has_update: boolean;
primary_image: string | null;
@@ -56,6 +65,7 @@ interface UpdatePreviewSummary {
interface UpdatePreview {
summary: UpdatePreviewSummary;
images: UpdatePreviewImage[];
build_services?: string[];
changelog: string | null;
}
@@ -345,6 +355,7 @@ export default function StackAnatomyPanel({
const showUpdateBanner = hasUpdate || rebuildAvailable;
const updateKind = updatePreview?.summary.update_kind ?? 'none';
const blocked = Boolean(updatePreview?.summary.blocked);
const updatedImages = (updatePreview?.images ?? []).filter((img) => img.has_update);
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
? 'danger'
: bump === 'minor' ? 'warn' : 'ok';
@@ -560,15 +571,27 @@ export default function StackAnatomyPanel({
<div className="min-w-0">
<div className="font-mono text-xs uppercase tracking-wide">
{hasBuildServices && !hasUpdate ? 'Rebuild available' : 'Update available'}
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && hasUpdate && (
<span className="text-foreground">
{' · '}
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
{' -> '}
<span className="text-foreground font-semibold">{updatePreview.summary.next_tag}</span>
</span>
)}
</div>
{updatedImages.length > 0 && (
<ul className="mt-1 space-y-0.5">
{updatedImages.map((img) => (
<li key={img.service} className="flex min-w-0 items-baseline gap-2 font-mono text-xs text-foreground">
<span className="min-w-0 truncate text-foreground/90">{imageName(img.image)}</span>
{img.current_tag && (
<span className="shrink-0 text-foreground/80">
<span className="text-stat-subtitle">{img.current_tag}</span>
{img.next_tag && img.next_tag !== img.current_tag && (
<>
{' -> '}
<span className="font-semibold">{img.next_tag}</span>
</>
)}
</span>
)}
</li>
))}
</ul>
)}
<div className="mt-1 font-mono text-xs text-foreground/80 leading-relaxed">
{[
bumpLabel,
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { assembleAnatomyInput, parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort } from './anatomy';
import { assembleAnatomyInput, parseAnatomy, parseEnvKeys, formatGitSource, imageName, primaryPublishedHostPort } from './anatomy';
const COMPOSE = `services:
plex:
@@ -73,6 +73,24 @@ describe('parseAnatomy', () => {
});
});
describe('imageName', () => {
it('strips a plain image tag', () => {
expect(imageName('nginx:1.25')).toBe('nginx');
});
it('keeps registry host and repository path', () => {
expect(imageName('ghcr.io/karakeep-app/karakeep:release')).toBe('ghcr.io/karakeep-app/karakeep');
});
it('treats only the colon after the last slash as a tag separator', () => {
expect(imageName('registry:5000/app:1.2')).toBe('registry:5000/app');
});
it('strips a digest suffix', () => {
expect(imageName('nginx:1.25@sha256:abc123')).toBe('nginx');
});
});
describe('primaryPublishedHostPort', () => {
const portsFrom = (ports: string) =>
parseAnatomy(`services:\n web:\n image: x\n ports:\n${ports}`)!.ports;
+11
View File
@@ -185,6 +185,17 @@ export function parseEnvKeys(envText: string): Set<string> {
return keys;
}
/**
* Strip the tag and any digest suffix from a container image reference,
* leaving the repository path. Only a colon after the last slash is treated as a
* tag separator so registry hosts with ports stay intact.
*/
export function imageName(ref: string): string {
const base = ref.split('@')[0];
const tagSep = base.indexOf(':', base.lastIndexOf('/') + 1);
return tagSep === -1 ? base : base.slice(0, tagSep);
}
export function formatGitSource(src: GitSourceInfo): string {
try {
const url = new URL(src.repo_url);