mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user