diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 65898bb9..c11f1360 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -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. diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index 28103b3a..aabe20d7 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -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); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index da9fae89..006bb64b 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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({
{hasBuildServices && !hasUpdate ? 'Rebuild available' : 'Update available'} - {updatePreview.summary.current_tag && updatePreview.summary.next_tag && hasUpdate && ( - - {' · '} - {updatePreview.summary.current_tag} - {' -> '} - {updatePreview.summary.next_tag} - - )}
+ {updatedImages.length > 0 && ( + + )}
{[ bumpLabel, diff --git a/frontend/src/lib/anatomy.test.ts b/frontend/src/lib/anatomy.test.ts index 9a4adf7a..bd447cf5 100644 --- a/frontend/src/lib/anatomy.test.ts +++ b/frontend/src/lib/anatomy.test.ts @@ -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; diff --git a/frontend/src/lib/anatomy.ts b/frontend/src/lib/anatomy.ts index d287f3aa..8dddb7ef 100644 --- a/frontend/src/lib/anatomy.ts +++ b/frontend/src/lib/anatomy.ts @@ -185,6 +185,17 @@ export function parseEnvKeys(envText: string): Set { 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);