mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 21:29:06 +00:00
1045 lines
32 KiB
Svelte
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>
|