Files
dockhand/routes/images/+page.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

1045 lines
32 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2 } from 'lucide-svelte';
import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import ImageHistoryModal from './ImageHistoryModal.svelte';
import ImageScanModal from './ImageScanModal.svelte';
import PushToRegistryModal from './PushToRegistryModal.svelte';
import type { ImageInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import CreateContainerModal from '../containers/CreateContainerModal.svelte';
import { onDockerEvent, isImageListChange } from '$lib/stores/events';
import { canAccess } from '$lib/stores/auth';
import { formatDate, appSettings } from '$lib/stores/settings';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
import type { DataGridSortState } from '$lib/components/data-grid/types';
let { data } = $props();
type SortField = 'name' | 'size' | 'created' | 'tags';
type SortDirection = 'asc' | 'desc';
interface Registry {
id: number;
name: string;
url: string;
hasCredentials: boolean;
is_default: boolean;
}
interface GroupedImage {
repoName: string;
tags: Array<{
tag: string;
fullRef: string;
imageId: string;
size: number;
created: number;
}>;
totalSize: number;
latestCreated: number;
imageIds: Set<string>;
}
// Check if a registry is Docker Hub
function isDockerHub(registry: Registry): boolean {
const url = registry.url.toLowerCase();
return url.includes('docker.io') ||
url.includes('hub.docker.com') ||
url.includes('registry.hub.docker.com');
}
let images = $state<ImageInfo[]>([]);
let loading = $state(true);
let envId = $state<number | null>(null);
// Registry state
let registries = $state<Registry[]>([]);
// Push modal state
let showPushModal = $state(false);
let pushingImage = $state<{ id: string; tag: string } | null>(null);
// Run modal state
let showRunModal = $state(false);
let prefilledImage = $state('');
// History modal state
let showHistoryModal = $state(false);
let historyImageId = $state('');
let historyImageName = $state('');
// Scan modal state
let showScanModal = $state(false);
let scanImageName = $state('');
// Scanner settings (loaded per-environment)
let scannerEnabled = $state(false);
// Search and sort state
let searchQuery = $state('');
let sortField = $state<SortField>('created');
let sortDirection = $state<SortDirection>('desc');
// Expanded rows state
let expandedRepos = $state<Set<string>>(new Set());
// Confirmation popover state
let confirmDeleteId = $state<string | null>(null);
// Delete error state
let deleteError = $state<{ id: string; message: string } | null>(null);
// Timeout tracking for cleanup
let pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
// Tag modal state
let showTagModal = $state(false);
let tagImageId = $state('');
let tagImageCurrentName = $state('');
let tagNewRepo = $state('');
let tagNewTag = $state('latest');
let tagging = $state(false);
// Prune state
let confirmPrune = $state(false);
let pruneStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
// Multi-select state
let selectedImages = $state<Set<string>>(new Set());
// Batch operation modal state
let showBatchOpModal = $state(false);
let batchOpTitle = $state('');
let batchOpOperation = $state('');
let batchOpItems = $state<Array<{ id: string; name: string }>>([]);
// Copy ID state
let copiedId = $state<string | null>(null);
async function copyImageId(imageId: string) {
try {
await navigator.clipboard.writeText(imageId);
copiedId = imageId;
pendingTimeouts.push(setTimeout(() => copiedId = null, 2000));
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Export state
let exportingId = $state<string | null>(null);
async function exportImage(imageRef: string, imageName: string) {
exportingId = imageRef;
try {
const compress = $appSettings.downloadFormat === 'tar.gz';
const url = appendEnvParam(`/api/images/${encodeURIComponent(imageName)}/export?compress=${compress}`, envId);
const link = document.createElement('a');
link.href = url;
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(`Exporting ${imageName}...`);
} catch (err) {
console.error('Failed to export image:', err);
toast.error(`Failed to export ${imageName}`);
} finally {
pendingTimeouts.push(setTimeout(() => {
if (exportingId === imageRef) exportingId = null;
}, 2000));
}
}
// Group images by repository name
const groupedImages = $derived.by(() => {
const groups = new Map<string, GroupedImage>();
for (const image of images) {
if (image.tags.length === 0) {
// Handle untagged images
const key = '<none>';
if (!groups.has(key)) {
groups.set(key, {
repoName: '<none>',
tags: [],
totalSize: 0,
latestCreated: 0,
imageIds: new Set()
});
}
const group = groups.get(key)!;
group.tags.push({
tag: image.id.slice(7, 19),
fullRef: image.id,
imageId: image.id,
size: image.size,
created: image.created
});
group.totalSize = Math.max(group.totalSize, image.size);
group.latestCreated = Math.max(group.latestCreated, image.created);
group.imageIds.add(image.id);
} else {
for (const fullTag of image.tags) {
const colonIndex = fullTag.lastIndexOf(':');
const repoName = colonIndex > 0 ? fullTag.slice(0, colonIndex) : fullTag;
const tagPart = colonIndex > 0 ? fullTag.slice(colonIndex + 1) : 'latest';
if (!groups.has(repoName)) {
groups.set(repoName, {
repoName,
tags: [],
totalSize: 0,
latestCreated: 0,
imageIds: new Set()
});
}
const group = groups.get(repoName)!;
// Avoid duplicate tags
if (!group.tags.some(t => t.fullRef === fullTag)) {
group.tags.push({
tag: tagPart,
fullRef: fullTag,
imageId: image.id,
size: image.size,
created: image.created
});
}
group.totalSize = Math.max(group.totalSize, image.size);
group.latestCreated = Math.max(group.latestCreated, image.created);
group.imageIds.add(image.id);
}
}
}
// Sort tags within each group by created date (newest first), with tag name as tiebreaker
for (const group of groups.values()) {
group.tags.sort((a, b) => {
const cmp = b.created - a.created;
return cmp !== 0 ? cmp : a.tag.localeCompare(b.tag);
});
}
return Array.from(groups.values());
});
// Filtered and sorted groups
const sortedGroups = $derived.by(() => {
const query = searchQuery.toLowerCase().trim();
let filtered = groupedImages;
if (query) {
filtered = groupedImages.filter(group => {
if (group.repoName.toLowerCase().includes(query)) return true;
if (group.tags.some(t => t.tag.toLowerCase().includes(query))) return true;
if (group.tags.some(t => t.imageId.toLowerCase().includes(query))) return true;
return false;
});
}
return [...filtered].sort((a, b) => {
let cmp = 0;
switch (sortField) {
case 'name':
cmp = a.repoName.localeCompare(b.repoName);
break;
case 'size':
cmp = a.totalSize - b.totalSize;
break;
case 'created':
cmp = a.latestCreated - b.latestCreated;
break;
case 'tags':
cmp = a.tags.length - b.tags.length;
break;
}
// Secondary sort by name for stability when primary values are equal
if (cmp === 0 && sortField !== 'name') {
cmp = a.repoName.localeCompare(b.repoName);
}
return sortDirection === 'asc' ? cmp : -cmp;
});
});
// Get all unique image IDs in current filter
const allFilteredImageIds = $derived(
new Set(sortedGroups.flatMap(g => Array.from(g.imageIds)))
);
// Check if all filtered images are selected
const allFilteredSelected = $derived(
allFilteredImageIds.size > 0 && Array.from(allFilteredImageIds).every(id => selectedImages.has(id))
);
const someFilteredSelected = $derived(
Array.from(allFilteredImageIds).some(id => selectedImages.has(id)) && !allFilteredSelected
);
const selectedInFilter = $derived(
images.filter(img => selectedImages.has(img.id) && allFilteredImageIds.has(img.id))
);
function toggleSelectAll() {
if (allFilteredSelected) {
allFilteredImageIds.forEach(id => selectedImages.delete(id));
} else {
allFilteredImageIds.forEach(id => selectedImages.add(id));
}
selectedImages = new Set(selectedImages);
}
function selectNone() {
selectedImages = new Set();
}
function toggleImageSelection(imageId: string) {
if (selectedImages.has(imageId)) {
selectedImages.delete(imageId);
} else {
selectedImages.add(imageId);
}
selectedImages = new Set(selectedImages);
}
function toggleRepo(repoName: string) {
if (expandedRepos.has(repoName)) {
expandedRepos.delete(repoName);
} else {
expandedRepos.add(repoName);
}
expandedRepos = new Set(expandedRepos);
}
// Filter registries to exclude Docker Hub
const pushableRegistries = $derived(registries.filter(r => {
const url = r.url.toLowerCase();
return !url.includes('docker.io') &&
!url.includes('hub.docker.com') &&
!url.includes('registry.hub.docker.com');
}));
async function fetchImages() {
// Only show loading skeleton on initial load
const isInitialLoad = images.length === 0;
if (isInitialLoad) loading = true;
try {
const url = appendEnvParam('/api/images', envId);
const response = await fetch(url);
if (!response.ok) {
// Handle stale environment ID (e.g., after database reset)
if (response.status === 404 && envId) {
clearStaleEnvironment(envId);
environments.refresh();
return;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
images = await response.json();
} catch (error) {
console.error('Failed to fetch images:', error);
toast.error('Failed to load images');
} finally {
if (isInitialLoad) loading = false;
}
}
async function fetchRegistries() {
try {
const response = await fetch('/api/registries');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
registries = await response.json();
} catch (error) {
console.error('Failed to fetch registries:', error);
}
}
async function fetchScannerSettings() {
if (envId === null) {
scannerEnabled = false;
return;
}
try {
const response = await fetch(`/api/settings/scanner?settingsOnly=true&env=${envId}`);
if (response.ok) {
const data = await response.json();
scannerEnabled = data.settings.scanner !== 'none';
}
} catch (error) {
console.error('Failed to fetch scanner settings:', error);
scannerEnabled = false;
}
}
// Track if initial fetch has been done
let initialFetchDone = $state(false);
$effect(() => {
const env = $currentEnvironment;
const newEnvId = env?.id ?? null;
// Only fetch if environment actually changed or this is initial load
if (env && (newEnvId !== envId || !initialFetchDone)) {
envId = newEnvId;
initialFetchDone = true;
fetchImages();
fetchScannerSettings();
} else if (!env) {
// No environment - clear data and stop loading
envId = null;
images = [];
loading = false;
}
});
function bulkRemove() {
batchOpTitle = `Removing ${selectedInFilter.length} image${selectedInFilter.length !== 1 ? 's' : ''}`;
batchOpOperation = 'remove';
batchOpItems = selectedInFilter.map(img => {
const displayName = img.tags.length > 0
? img.tags[0]
: img.id.slice(7, 19);
return { id: img.id, name: displayName };
});
showBatchOpModal = true;
}
function handleBatchComplete() {
selectedImages = new Set();
fetchImages();
}
async function pruneImages() {
pruneStatus = 'pruning';
confirmPrune = false;
try {
const response = await fetch(appendEnvParam('/api/prune/images', envId), { method: 'POST' });
if (response.ok) {
pruneStatus = 'success';
toast.success('Unused images pruned');
await fetchImages();
} else {
pruneStatus = 'error';
toast.error('Failed to prune images');
}
} catch (error) {
pruneStatus = 'error';
toast.error('Failed to prune images');
}
pendingTimeouts.push(setTimeout(() => { pruneStatus = 'idle'; }, 3000));
}
async function removeImage(id: string, tagName: string) {
deleteError = null;
try {
const response = await fetch(appendEnvParam(`/api/images/${encodeURIComponent(id)}?force=true`, envId), { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
deleteError = { id, message: data.error || 'Failed to delete image' };
toast.error(`Failed to delete ${tagName}`);
pendingTimeouts.push(setTimeout(() => {
if (deleteError?.id === id) deleteError = null;
}, 5000));
return;
}
toast.success(`Deleted ${tagName}`);
await fetchImages();
} catch (error) {
console.error('Failed to remove image:', error);
deleteError = { id, message: 'Failed to delete image' };
toast.error(`Failed to delete ${tagName}`);
pendingTimeouts.push(setTimeout(() => {
if (deleteError?.id === id) deleteError = null;
}, 5000));
}
}
function openTagModal(imageId: string, currentName: string) {
tagImageId = imageId;
tagImageCurrentName = currentName;
if (currentName.includes(':')) {
const parts = currentName.split(':');
tagNewRepo = parts.slice(0, -1).join(':');
tagNewTag = parts[parts.length - 1];
} else {
tagNewRepo = currentName;
tagNewTag = 'latest';
}
showTagModal = true;
}
async function tagImage() {
if (!tagNewRepo.trim()) return;
tagging = true;
const newTag = `${tagNewRepo.trim()}:${tagNewTag.trim() || 'latest'}`;
try {
const response = await fetch(appendEnvParam(`/api/images/${encodeURIComponent(tagImageId)}/tag`, envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: tagNewRepo.trim(), tag: tagNewTag.trim() || 'latest' })
});
if (response.ok) {
toast.success(`Tagged as ${newTag}`);
showTagModal = false;
await fetchImages();
} else {
const data = await response.json();
toast.error(data.error || 'Failed to tag image');
}
} catch (error) {
toast.error('Failed to tag image');
} finally {
tagging = false;
}
}
function openPushModal(imageId: string, tagName: string) {
pushingImage = { id: imageId, tag: tagName };
showPushModal = true;
}
function openRunModal(tagName: string) {
prefilledImage = tagName;
showRunModal = true;
}
function openHistoryModal(imageId: string, imageName: string) {
historyImageId = imageId;
historyImageName = imageName;
showHistoryModal = true;
}
function openScanModal(tagName: string) {
scanImageName = tagName;
showScanModal = true;
}
function formatSize(bytes: number): string {
const mb = bytes / (1024 * 1024);
if (mb < 1024) {
return `${mb.toFixed(1)} MB`;
}
return `${(mb / 1024).toFixed(2)} GB`;
}
function formatImageDate(timestamp: number): string {
return formatDate(new Date(timestamp * 1000));
}
function toggleSort(field: SortField) {
if (sortField === field) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDirection = field === 'created' ? 'desc' : 'asc';
}
}
// Handle tab visibility changes (e.g., user switches back from another tab)
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && envId) {
fetchImages();
}
}
onMount(() => {
// Initial fetch is handled by $effect - no need to duplicate here
// Only fetch registries if user has permission
if ($canAccess('registries', 'view')) {
fetchRegistries();
}
// Listen for tab visibility changes to refresh when user returns
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('resume', handleVisibilityChange);
const unsubscribe = onDockerEvent((event) => {
if (envId && isImageListChange(event)) {
fetchImages();
}
});
const interval = setInterval(() => {
if (envId) fetchImages();
}, 30000);
return () => {
clearInterval(interval);
unsubscribe();
};
});
onDestroy(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
pendingTimeouts = [];
});
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<PageHeader
icon={Images}
title="Images"
count={sortedGroups.length}
total={searchQuery && sortedGroups.length !== groupedImages.length ? groupedImages.length : undefined}
/>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search images..."
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
class="pl-8 h-8 w-48 text-sm"
/>
</div>
{#if $canAccess('images', 'remove')}
<ConfirmPopover
open={confirmPrune}
action="Prune"
itemType="dangling images"
title="Prune images"
position="left"
onConfirm={pruneImages}
onOpenChange={(open) => confirmPrune = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}">
{#if pruneStatus === 'pruning'}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
{:else if pruneStatus === 'success'}
<Check class="w-3.5 h-3.5 text-green-600" />
{:else if pruneStatus === 'error'}
<XCircle class="w-3.5 h-3.5 text-destructive" />
{:else}
<Icon iconNode={broom} class="w-3.5 h-3.5" />
{/if}
Prune
</span>
{/snippet}
</ConfirmPopover>
{/if}
<Button size="sm" variant="outline" onclick={fetchImages}>Refresh</Button>
</div>
</div>
<!-- Selection bar -->
{#if selectedImages.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
</button>
{#if $canAccess('images', 'remove')}
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all disabled:opacity-50 cursor-pointer"
onclick={bulkRemove}
disabled={selectedInFilter.length === 0}
>
<Trash2 class="w-3 h-3" />
Delete
</button>
{/if}
</div>
{/if}
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
{:else if !loading && images.length === 0}
<EmptyState
icon={Images}
title="No images found"
description="Pull an image from a registry to get started"
/>
{:else}
<DataGrid
data={sortedGroups}
keyField="repoName"
gridId="images"
loading={loading}
expandable
bind:expandedKeys={expandedRepos}
sortState={{ field: sortField, direction: sortDirection }}
onSortChange={(state) => {
sortField = state.field as SortField;
sortDirection = state.direction;
}}
onRowClick={(group) => toggleRepo(group.repoName)}
rowClass={(group) => {
const isExp = expandedRepos.has(group.repoName);
return isExp ? 'bg-muted/40' : '';
}}
>
{#snippet headerCell(column, sortState)}
{#if column.id === 'select'}
{@const allImageIds = sortedGroups.flatMap(g => Array.from(g.imageIds))}
{@const allSelected = allImageIds.length > 0 && allImageIds.every(id => selectedImages.has(id))}
{@const someSelected = allImageIds.some(id => selectedImages.has(id)) && !allSelected}
<button
type="button"
onclick={() => {
if (allSelected) {
selectedImages = new Set();
} else {
selectedImages = new Set(allImageIds);
}
}}
class="flex items-center justify-center transition-colors opacity-40 hover:opacity-100 cursor-pointer"
title={allSelected ? 'Deselect all' : 'Select all'}
>
{#if allSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else if someSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<Square class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{/snippet}
{#snippet cell(column, group, rowState)}
{#if column.id === 'select'}
<!-- Custom selection on image IDs -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
const allSelected = Array.from(group.imageIds).every(id => selectedImages.has(id));
if (allSelected) {
group.imageIds.forEach(id => selectedImages.delete(id));
} else {
group.imageIds.forEach(id => selectedImages.add(id));
}
selectedImages = new Set(selectedImages);
}}
class="flex items-center justify-center transition-colors cursor-pointer {Array.from(group.imageIds).some(id => selectedImages.has(id)) ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if Array.from(group.imageIds).every(id => selectedImages.has(id))}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else if Array.from(group.imageIds).some(id => selectedImages.has(id))}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground opacity-50" />
{:else}
<Square class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{:else if column.id === 'expand'}
{@const hasMultipleTags = group.tags.length > 1}
{#if hasMultipleTags}
{#if rowState.isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
{/if}
{/if}
{:else if column.id === 'image'}
<div class="flex items-center gap-1.5">
<span class="text-xs truncate" title={group.repoName}>
{group.repoName === '<none>' ? '<untagged>' : group.repoName}
</span>
{#if group.tags.length === 1}
<span class="text-2xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground font-medium">
{group.tags[0].tag}
</span>
{/if}
</div>
{:else if column.id === 'tags'}
<Badge variant="secondary" class="text-xs">
{group.tags.length}
</Badge>
{:else if column.id === 'size'}
<span class="text-xs">{formatSize(group.totalSize)}</span>
{:else if column.id === 'updated'}
<span class="text-xs text-muted-foreground">{formatImageDate(group.latestCreated)}</span>
{:else if column.id === 'actions'}
<!-- Quick actions for first tag only when collapsed -->
{#if !rowState.isExpanded && group.tags.length > 0}
{@const firstTag = group.tags[0]}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="flex items-center justify-end gap-0.5" onclick={(e) => e.stopPropagation()}>
{#if $canAccess('containers', 'create')}
<button
type="button"
onclick={() => openRunModal(firstTag.fullRef)}
title="Run container"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-green-600" />
</button>
{/if}
{#if scannerEnabled && $canAccess('images', 'inspect')}
<button
type="button"
onclick={() => openScanModal(firstTag.fullRef)}
title="Scan for vulnerabilities"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<ShieldCheck class="w-3.5 h-3.5 text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if $canAccess('images', 'push')}
<button
type="button"
onclick={() => openPushModal(firstTag.imageId, firstTag.fullRef)}
title="Push to registry"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Upload class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
{/if}
{/if}
{/snippet}
{#snippet expandedRow(group, rowState)}
<div class="p-4 pl-12 shadow-inner bg-muted/30">
<DataGrid
gridId="imageTags"
data={group.tags}
keyField="fullRef"
selectable={false}
expandable={false}
loading={false}
class="nested-grid"
>
{#snippet cell(column, tagInfo, rowState)}
{#if column.id === 'tag'}
<div class="flex items-center gap-1.5">
<Tag class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="{tagInfo.tag === 'latest' ? 'text-blue-600 dark:text-blue-400' : ''}">{tagInfo.tag}</span>
</div>
{:else if column.id === 'id'}
<button
type="button"
onclick={() => copyImageId(tagInfo.imageId)}
class="inline-flex items-center gap-1 hover:bg-muted px-1 py-0.5 rounded transition-colors cursor-pointer"
title={copiedId === tagInfo.imageId ? 'Copied!' : 'Click to copy full ID'}
>
<code class="text-2xs text-muted-foreground">{tagInfo.imageId.slice(7, 19)}</code>
{#if copiedId === tagInfo.imageId}
<Check class="w-3 h-3 text-green-500" />
{/if}
</button>
{:else if column.id === 'size'}
<span class="text-muted-foreground">{formatSize(tagInfo.size)}</span>
{:else if column.id === 'created'}
<span class="text-muted-foreground">{formatImageDate(tagInfo.created)}</span>
{:else if column.id === 'actions'}
<div class="flex items-center gap-1">
{#if $canAccess('images', 'inspect')}
<button
type="button"
onclick={() => openHistoryModal(tagInfo.imageId, tagInfo.fullRef)}
title="View layers"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Layers class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('containers', 'create')}
<button
type="button"
onclick={() => openRunModal(tagInfo.fullRef)}
title="Run container"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-600" />
</button>
{/if}
{#if scannerEnabled && $canAccess('images', 'inspect')}
<button
type="button"
onclick={() => openScanModal(tagInfo.fullRef)}
title="Scan for vulnerabilities"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<ShieldCheck class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if $canAccess('images', 'push')}
<button
type="button"
onclick={() => openPushModal(tagInfo.imageId, tagInfo.fullRef)}
title="Push to registry"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Upload class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('images', 'inspect')}
<button
type="button"
onclick={() => exportImage(tagInfo.fullRef, tagInfo.fullRef)}
title="Export image as {$appSettings.downloadFormat}"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer {exportingId === tagInfo.fullRef ? 'animate-pulse' : ''}"
disabled={exportingId === tagInfo.fullRef}
>
<Download class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('images', 'build')}
<button
type="button"
onclick={() => openTagModal(tagInfo.imageId, tagInfo.fullRef)}
title="Tag image"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Tag class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('images', 'remove')}
<div class="relative">
<ConfirmPopover
open={confirmDeleteId === tagInfo.fullRef}
action="Delete"
itemType="image"
itemName={tagInfo.fullRef}
title="Remove"
onConfirm={() => removeImage(tagInfo.fullRef, tagInfo.fullRef)}
onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
</div>
{/if}
</div>
{/if}
{/snippet}
</DataGrid>
</div>
{/snippet}
</DataGrid>
{/if}
</div>
<!-- Push to Registry Modal -->
{#if pushingImage}
<PushToRegistryModal
bind:open={showPushModal}
imageId={pushingImage.id}
imageName={pushingImage.tag}
{registries}
{envId}
onComplete={fetchImages}
/>
{/if}
<!-- Image History Modal -->
<ImageHistoryModal
bind:open={showHistoryModal}
imageId={historyImageId}
imageName={historyImageName}
/>
<!-- Create Container Modal -->
<CreateContainerModal
bind:open={showRunModal}
onClose={() => showRunModal = false}
onSuccess={() => showRunModal = false}
{prefilledImage}
skipPullTab={true}
/>
<!-- Vulnerability Scan Modal -->
<ImageScanModal
bind:open={showScanModal}
imageName={scanImageName}
mode="scan"
{envId}
/>
<!-- Batch Operation Modal -->
<BatchOperationModal
bind:open={showBatchOpModal}
title={batchOpTitle}
operation={batchOpOperation}
entityType="images"
items={batchOpItems}
envId={envId ?? undefined}
options={{ force: true }}
onClose={() => showBatchOpModal = false}
onComplete={handleBatchComplete}
/>
<!-- Tag Image Dialog -->
<Dialog.Root bind:open={showTagModal}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Tag class="w-5 h-5" />
Tag image
</Dialog.Title>
<Dialog.Description>
Add a new tag to <span class="font-mono text-foreground">{tagImageCurrentName}</span>
</Dialog.Description>
</Dialog.Header>
<div class="py-4 space-y-4">
<div>
<Label for="tagRepo">Repository name</Label>
<Input
id="tagRepo"
bind:value={tagNewRepo}
placeholder="e.g., myregistry/myimage"
class="mt-2"
/>
</div>
<div>
<Label for="tagTag">Tag</Label>
<Input
id="tagTag"
bind:value={tagNewTag}
placeholder="e.g., latest, v1.0.0"
class="mt-2"
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && !tagging && tagNewRepo.trim()) {
tagImage();
}
}}
/>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => showTagModal = false} disabled={tagging}>
Cancel
</Button>
<Button
onclick={tagImage}
disabled={tagging || !tagNewRepo.trim()}
>
{#if tagging}
<RefreshCw class="w-4 h-4 mr-2 animate-spin" />
Tagging...
{:else}
Tag
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>