mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-09 13:24:51 +00:00
Initial commit
This commit is contained in:
633
routes/registry/+page.svelte
Normal file
633
routes/registry/+page.svelte
Normal file
@@ -0,0 +1,633 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Search, Download, Star, RefreshCw, Settings2, List, Play, Copy, Clipboard, Check, Server, Icon, ChevronRight, ChevronDown, Loader2, Tag, Calendar, HardDrive, Trash2 } from 'lucide-svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { whale } from '@lucide/lab';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import CreateContainerModal from '../containers/CreateContainerModal.svelte';
|
||||
import ImagePullModal from './ImagePullModal.svelte';
|
||||
import CopyToRegistryModal from './CopyToRegistryModal.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
|
||||
interface Registry {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
username?: string;
|
||||
hasCredentials: boolean;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
name: string;
|
||||
description: string;
|
||||
star_count: number;
|
||||
is_official: boolean;
|
||||
is_automated: boolean;
|
||||
}
|
||||
|
||||
interface TagInfo {
|
||||
name: string;
|
||||
size?: number;
|
||||
lastUpdated?: string;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
interface ExpandedImageState {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
tags: TagInfo[];
|
||||
}
|
||||
|
||||
let registries = $state<Registry[]>([]);
|
||||
let expandedImages = $state<Record<string, ExpandedImageState>>({});
|
||||
let selectedRegistryId = $state<number | null>(null);
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
let loading = $state(false);
|
||||
let browsing = $state(false);
|
||||
let searched = $state(false);
|
||||
let browseMode = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
// Copy to registry modal state
|
||||
let showCopyModal = $state(false);
|
||||
let copyImageName = $state('');
|
||||
let copyImageTag = $state('latest');
|
||||
|
||||
// Run modal state
|
||||
let showRunModal = $state(false);
|
||||
let runImageName = $state('');
|
||||
|
||||
// Pull modal state
|
||||
let showPullModal = $state(false);
|
||||
let pullImageName = $state('');
|
||||
|
||||
// Scanner settings - scanning enabled if scanner is configured
|
||||
let envHasScanning = $state(false);
|
||||
|
||||
// Delete confirmation state
|
||||
let confirmDeleteKey = $state<string | null>(null);
|
||||
let deleting = $state(false);
|
||||
|
||||
|
||||
let scrollContainer: HTMLDivElement | undefined;
|
||||
|
||||
let selectedRegistry = $derived(registries.find(r => r.id === selectedRegistryId));
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Check if registry supports browsing (not Docker Hub)
|
||||
let supportsBrowsing = $derived(() => {
|
||||
if (!selectedRegistry) return false;
|
||||
return !isDockerHub(selectedRegistry);
|
||||
});
|
||||
|
||||
// Get registries that can be pushed to (exclude Docker Hub and source registry)
|
||||
let pushableRegistries = $derived(registries.filter(r => {
|
||||
return !isDockerHub(r) && r.id !== selectedRegistryId;
|
||||
}));
|
||||
|
||||
async function fetchRegistries() {
|
||||
try {
|
||||
const response = await fetch('/api/registries');
|
||||
registries = await response.json();
|
||||
if (!selectedRegistryId && registries.length > 0) {
|
||||
const defaultRegistry = registries.find(r => r.is_default);
|
||||
selectedRegistryId = defaultRegistry?.id ?? registries[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch registries:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScannerSettings(envId?: number | null) {
|
||||
try {
|
||||
const url = envId ? `/api/settings/scanner?env=${envId}&settingsOnly=true` : '/api/settings/scanner?settingsOnly=true';
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const scanner = data.settings?.scanner ?? 'none';
|
||||
// Scanning is enabled if a scanner is configured
|
||||
envHasScanning = scanner !== 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch scanner settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch scanner settings when environment changes
|
||||
$effect(() => {
|
||||
const envId = $currentEnvironment?.id;
|
||||
fetchScannerSettings(envId);
|
||||
});
|
||||
|
||||
async function search() {
|
||||
if (!searchTerm.trim()) return;
|
||||
|
||||
loading = true;
|
||||
searched = true;
|
||||
browseMode = false;
|
||||
errorMessage = '';
|
||||
try {
|
||||
let url = `/api/registry/search?term=${encodeURIComponent(searchTerm)}`;
|
||||
if (selectedRegistryId) {
|
||||
url += `®istry=${selectedRegistryId}`;
|
||||
}
|
||||
if ($currentEnvironment?.id) {
|
||||
url += `&env=${$currentEnvironment.id}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
results = await response.json();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
errorMessage = data.error || 'Search failed';
|
||||
results = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to search images:', error);
|
||||
errorMessage = 'Failed to search images';
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function browse() {
|
||||
if (!selectedRegistryId) return;
|
||||
|
||||
browsing = true;
|
||||
searched = true;
|
||||
browseMode = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
const response = await fetch(`/api/registry/catalog?registry=${selectedRegistryId}`);
|
||||
if (response.ok) {
|
||||
results = await response.json();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
errorMessage = data.error || 'Failed to browse registry';
|
||||
results = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to browse registry:', error);
|
||||
errorMessage = 'Failed to browse registry';
|
||||
results = [];
|
||||
} finally {
|
||||
browsing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
search();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function buildFullImageName(name: string): string {
|
||||
// Build full image name with registry prefix if applicable
|
||||
if (selectedRegistry && supportsBrowsing()) {
|
||||
// Extract host from URL (e.g., "https://registry.example.com" -> "registry.example.com")
|
||||
const urlObj = new URL(selectedRegistry.url);
|
||||
return `${urlObj.host}/${name}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function handleRegistryChange() {
|
||||
// Clear results when registry changes
|
||||
results = [];
|
||||
searched = false;
|
||||
browseMode = false;
|
||||
errorMessage = '';
|
||||
expandedImages = {};
|
||||
}
|
||||
|
||||
async function toggleImageExpansion(imageName: string) {
|
||||
if (expandedImages[imageName]) {
|
||||
// Collapse
|
||||
const { [imageName]: _, ...rest } = expandedImages;
|
||||
expandedImages = rest;
|
||||
} else {
|
||||
// Expand and fetch tags
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: true, error: '', tags: [] }
|
||||
};
|
||||
|
||||
try {
|
||||
let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}`;
|
||||
if (selectedRegistryId) {
|
||||
url += `®istry=${selectedRegistryId}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const tags = await response.json();
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: false, error: '', tags }
|
||||
};
|
||||
} else {
|
||||
const data = await response.json();
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: false, error: data.error || 'Failed to fetch tags', tags: [] }
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: false, error: error.message || 'Failed to fetch tags', tags: [] }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'today';
|
||||
if (diffDays === 1) return 'yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
||||
return `${Math.floor(diffDays / 365)} years ago`;
|
||||
}
|
||||
|
||||
function openCopyModal(imageName: string, tag?: string) {
|
||||
// Build full image name with registry prefix (no tag - modal handles that)
|
||||
copyImageName = buildFullImageName(imageName);
|
||||
copyImageTag = tag || 'latest';
|
||||
showCopyModal = true;
|
||||
}
|
||||
|
||||
function openRunModal(imageName: string, tag?: string) {
|
||||
// Build full image name with registry prefix if applicable
|
||||
const imageWithTag = tag ? `${imageName}:${tag}` : imageName;
|
||||
if (selectedRegistry && supportsBrowsing()) {
|
||||
const urlObj = new URL(selectedRegistry.url);
|
||||
runImageName = `${urlObj.host}/${imageWithTag}`;
|
||||
} else {
|
||||
runImageName = imageWithTag;
|
||||
}
|
||||
showRunModal = true;
|
||||
}
|
||||
|
||||
function openPullModal(imageName: string, tag?: string) {
|
||||
// Build full image name with registry prefix if applicable
|
||||
const imageWithTag = tag ? `${imageName}:${tag}` : imageName;
|
||||
pullImageName = buildFullImageName(imageWithTag);
|
||||
showPullModal = true;
|
||||
}
|
||||
|
||||
async function deleteTag(imageName: string, tag: string) {
|
||||
if (!selectedRegistryId) return;
|
||||
|
||||
deleting = true;
|
||||
try {
|
||||
const response = await fetch(`/api/registry/image?registry=${selectedRegistryId}&image=${encodeURIComponent(imageName)}&tag=${encodeURIComponent(tag)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(`Deleted ${imageName}:${tag}`);
|
||||
// Refresh tags for this image
|
||||
const state = expandedImages[imageName];
|
||||
if (state) {
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: {
|
||||
...state,
|
||||
tags: state.tags.filter(t => t.name !== tag)
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.error || 'Failed to delete image');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete image');
|
||||
} finally {
|
||||
deleting = false;
|
||||
confirmDeleteKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeClasses(type: string): string {
|
||||
const base = 'text-xs px-1.5 py-0.5 rounded-sm font-medium inline-block w-14 text-center';
|
||||
switch (type) {
|
||||
case 'official':
|
||||
return `${base} bg-emerald-200 dark:bg-emerald-800 text-emerald-900 dark:text-emerald-100`;
|
||||
case 'automated':
|
||||
return `${base} bg-sky-200 dark:bg-sky-800 text-sky-900 dark:text-sky-100`;
|
||||
default:
|
||||
return `${base} bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Only fetch registries if user has permission
|
||||
if ($canAccess('registries', 'view')) {
|
||||
fetchRegistries();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col gap-3 overflow-hidden">
|
||||
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
|
||||
<PageHeader icon={Download} title="Registry" showConnection={false} />
|
||||
{#if $canAccess('registries', 'edit')}
|
||||
<a href="/settings?tab=registries" class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs">
|
||||
<Settings2 class="w-4 h-4" />
|
||||
Manage registries
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Registry Selector + Search Bar -->
|
||||
<div class="shrink-0 flex gap-2">
|
||||
<Select.Root type="single" value={selectedRegistryId ? String(selectedRegistryId) : undefined} onValueChange={(v) => { selectedRegistryId = Number(v); handleRegistryChange(); }}>
|
||||
<Select.Trigger class="h-9 w-48">
|
||||
{@const selected = registries.find(r => r.id === selectedRegistryId)}
|
||||
{#if selected && isDockerHub(selected)}
|
||||
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{:else}
|
||||
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{/if}
|
||||
<span>{selected ? `${selected.name}${selected.hasCredentials ? ' (auth)' : ''}` : 'Select registry'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each registries as registry}
|
||||
<Select.Item value={String(registry.id)} label={registry.name}>
|
||||
{#if isDockerHub(registry)}
|
||||
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{:else}
|
||||
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{/if}
|
||||
{registry.name}
|
||||
{#if registry.hasCredentials}
|
||||
<Badge variant="outline" class="ml-2 text-xs">auth</Badge>
|
||||
{/if}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={selectedRegistry ? `Search ${selectedRegistry.name} for images...` : 'Search for images...'}
|
||||
bind:value={searchTerm}
|
||||
onkeydown={handleKeydown}
|
||||
class="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onclick={search} disabled={loading || browsing || !searchTerm.trim()}>
|
||||
{#if loading}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Search
|
||||
</Button>
|
||||
{#if supportsBrowsing()}
|
||||
<Button variant="outline" onclick={browse} disabled={loading || browsing}>
|
||||
{#if browsing}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<List class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Browse
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if loading || browsing}
|
||||
<p class="text-muted-foreground text-sm">{browsing ? 'Loading catalog...' : 'Searching...'}</p>
|
||||
{:else if errorMessage}
|
||||
<p class="text-red-600 dark:text-red-400 text-sm">{errorMessage}</p>
|
||||
{:else if searched && results.length === 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{browseMode ? 'No images found in this registry' : `No images found for "${searchTerm}"`}
|
||||
</p>
|
||||
{:else if results.length > 0}
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
class="flex-1 min-h-0 rounded-lg overflow-auto"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr class="border-b">
|
||||
<th class="text-left py-1.5 px-2 font-medium">Name</th>
|
||||
{#if !browseMode}
|
||||
<th class="text-left py-1.5 px-2 font-medium">Description</th>
|
||||
<th class="text-center py-1.5 px-2 font-medium w-16">Stars</th>
|
||||
<th class="text-center py-1.5 px-2 font-medium w-20">Type</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each results as result (result.name)}
|
||||
{@const isExpanded = !!expandedImages[result.name]}
|
||||
{@const expandState = expandedImages[result.name]}
|
||||
<!-- Main row -->
|
||||
<tr
|
||||
class="border-b border-muted hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onclick={() => toggleImageExpansion(result.name)}
|
||||
>
|
||||
<td class="py-1.5 px-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if 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}
|
||||
<code class="text-xs">{result.name}</code>
|
||||
</div>
|
||||
</td>
|
||||
{#if !browseMode}
|
||||
<td class="py-1.5 px-2">
|
||||
<span class="text-xs text-muted-foreground line-clamp-1" title={result.description}>
|
||||
{result.description || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1.5 px-2 text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Star class="w-3 h-3 text-yellow-500" />
|
||||
<span class="text-xs">{result.star_count.toLocaleString()}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-1.5 px-2 text-center">
|
||||
{#if result.is_official}
|
||||
<span class={getTypeClasses('official')}>Official</span>
|
||||
{:else if result.is_automated}
|
||||
<span class={getTypeClasses('automated')}>Auto</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
<!-- Expanded tags row -->
|
||||
{#if isExpanded}
|
||||
<tr class="border-b border-muted bg-muted/20">
|
||||
<td colspan={browseMode ? 1 : 4} class="py-2 px-2 pl-8">
|
||||
{#if expandState?.loading}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Loading tags...</span>
|
||||
</div>
|
||||
{:else if expandState?.error}
|
||||
<div class="text-xs text-red-500 py-2">
|
||||
{expandState.error}
|
||||
</div>
|
||||
{:else if expandState?.tags && expandState.tags.length > 0}
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<table class="text-xs">
|
||||
<thead class="text-muted-foreground sticky top-0 bg-muted/50">
|
||||
<tr>
|
||||
<th class="text-left py-1 px-2 pr-4 font-medium">Tag</th>
|
||||
<th class="text-left py-1 px-2 pr-4 font-medium">Size</th>
|
||||
<th class="text-left py-1 px-2 pr-4 font-medium">Modified</th>
|
||||
<th class="text-left py-1 px-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each expandState.tags as tag}
|
||||
<tr class="hover:bg-muted/30 transition-colors">
|
||||
<td class="py-1 px-2 pr-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tag class="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<code class="font-medium">{tag.name}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatBytes(tag.size)}
|
||||
</td>
|
||||
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(tag.lastUpdated)}
|
||||
</td>
|
||||
<td class="py-1 px-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => openPullModal(result.name, tag.name)}
|
||||
title={envHasScanning ? "Pull and scan this tag" : "Pull this tag"}
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Download class="w-3 h-3 text-muted-foreground" />
|
||||
<span class="text-muted-foreground">{envHasScanning ? 'Pull & scan' : 'Pull'}</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openRunModal(result.name, tag.name)}
|
||||
title="Run container with this tag"
|
||||
class="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{#if pushableRegistries.length > 0}
|
||||
<button
|
||||
onclick={() => openCopyModal(result.name, tag.name)}
|
||||
title="Copy to another registry"
|
||||
class="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if supportsBrowsing()}
|
||||
{@const deleteKey = `${result.name}:${tag.name}`}
|
||||
<ConfirmPopover
|
||||
title="Delete tag"
|
||||
description="Are you sure you want to delete {result.name}:{tag.name}? This cannot be undone."
|
||||
confirmText="Delete"
|
||||
open={confirmDeleteKey === deleteKey}
|
||||
onConfirm={() => deleteTag(result.name, tag.name)}
|
||||
onOpenChange={(open) => confirmDeleteKey = open ? deleteKey : null}
|
||||
>
|
||||
<button
|
||||
title="Delete this tag"
|
||||
class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 class="w-3 h-3 text-muted-foreground hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground py-2">
|
||||
No tags found
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-12 text-muted-foreground">
|
||||
<Download class="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p class="text-sm">
|
||||
{#if supportsBrowsing()}
|
||||
Search or browse {selectedRegistry?.name || 'a registry'} to find images
|
||||
{:else}
|
||||
Search {selectedRegistry?.name || 'a registry'} to find and pull images
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Copy to Registry Modal -->
|
||||
<CopyToRegistryModal
|
||||
bind:open={showCopyModal}
|
||||
imageName={copyImageName}
|
||||
initialTag={copyImageTag}
|
||||
registries={registries}
|
||||
sourceRegistryId={selectedRegistryId}
|
||||
/>
|
||||
|
||||
<!-- Create Container Modal -->
|
||||
<CreateContainerModal bind:open={showRunModal} prefilledImage={runImageName} autoPull={true} />
|
||||
|
||||
<!-- Pull/Scan Modal -->
|
||||
<ImagePullModal bind:open={showPullModal} imageName={pullImageName} envHasScanning={envHasScanning} />
|
||||
497
routes/registry/CopyToRegistryModal.svelte
Normal file
497
routes/registry/CopyToRegistryModal.svelte
Normal file
@@ -0,0 +1,497 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { CheckCircle2, XCircle, Download, Upload, Server, Settings2, Copy, Check, Clipboard, Icon, ShieldCheck, ShieldAlert, ShieldX, ArrowBigRight } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
import PullTab from '$lib/components/PullTab.svelte';
|
||||
import ScanTab from '$lib/components/ScanTab.svelte';
|
||||
import PushTab from '$lib/components/PushTab.svelte';
|
||||
import type { ScanResult } from '$lib/components/ScanTab.svelte';
|
||||
|
||||
interface Registry {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
username?: string;
|
||||
hasCredentials: boolean;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
imageName: string;
|
||||
initialTag?: string;
|
||||
registries: Registry[];
|
||||
sourceRegistryId?: number | null;
|
||||
envId?: number | null;
|
||||
onClose?: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
imageName,
|
||||
initialTag = 'latest',
|
||||
registries,
|
||||
sourceRegistryId = null,
|
||||
envId,
|
||||
onClose,
|
||||
onComplete
|
||||
}: Props = $props();
|
||||
|
||||
// Component refs
|
||||
let pullTabRef = $state<PullTab | undefined>();
|
||||
let scanTabRef = $state<ScanTab | undefined>();
|
||||
let pushTabRef = $state<PushTab | undefined>();
|
||||
|
||||
// Step state: configure → pull → scan (optional) → push
|
||||
let currentStep = $state<'configure' | 'pull' | 'scan' | 'push'>('configure');
|
||||
|
||||
// Configuration
|
||||
let sourceTag = $state('latest');
|
||||
let targetRegistryId = $state<number | null>(null);
|
||||
let customTag = $state('');
|
||||
|
||||
// Scanner settings - scanning enabled if scanner is configured
|
||||
let envHasScanning = $state(false);
|
||||
|
||||
// Status
|
||||
let pullStatus = $state<'idle' | 'pulling' | 'complete' | 'error'>('idle');
|
||||
let pullStarted = $state(false);
|
||||
let scanStatus = $state<'idle' | 'scanning' | 'complete' | 'error'>('idle');
|
||||
let scanStarted = $state(false);
|
||||
let scanResults = $state<ScanResult[]>([]);
|
||||
let pushStatus = $state<'idle' | 'pushing' | 'complete' | 'error'>('idle');
|
||||
let pushStarted = $state(false);
|
||||
let copiedToClipboard = $state(false);
|
||||
|
||||
// Computed
|
||||
const sourceRegistry = $derived(registries.find(r => r.id === sourceRegistryId));
|
||||
const pushableRegistries = $derived(registries.filter(r => !isDockerHub(r) && r.id !== sourceRegistryId));
|
||||
const targetRegistry = $derived(registries.find(r => r.id === targetRegistryId));
|
||||
|
||||
const fullSourceImageName = $derived(() => {
|
||||
const tagToUse = sourceTag.trim() || 'latest';
|
||||
const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`;
|
||||
if (sourceRegistry && !isDockerHub(sourceRegistry)) {
|
||||
const urlObj = new URL(sourceRegistry.url);
|
||||
return `${urlObj.host}/${imageWithTag}`;
|
||||
}
|
||||
return imageWithTag;
|
||||
});
|
||||
|
||||
const targetImageName = $derived(() => {
|
||||
if (!targetRegistryId || !targetRegistry) return customTag || 'image:latest';
|
||||
const host = new URL(targetRegistry.url).host;
|
||||
const tag = customTag ? (customTag.includes(':') ? customTag : customTag + ':latest') : 'image:latest';
|
||||
return `${host}/${tag}`;
|
||||
});
|
||||
|
||||
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || pushStatus === 'pushing');
|
||||
|
||||
const totalVulnerabilities = $derived(
|
||||
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
|
||||
);
|
||||
|
||||
const hasCriticalOrHigh = $derived(
|
||||
scanResults.some(r => r.summary.critical > 0 || r.summary.high > 0)
|
||||
);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
async function fetchScannerSettings() {
|
||||
try {
|
||||
const effectiveEnvId = envId ?? $currentEnvironment?.id;
|
||||
const url = effectiveEnvId
|
||||
? `/api/settings/scanner?env=${effectiveEnvId}&settingsOnly=true`
|
||||
: '/api/settings/scanner?settingsOnly=true';
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const scanner = data.settings?.scanner ?? 'none';
|
||||
// Scanning is enabled if a scanner is configured
|
||||
envHasScanning = scanner !== 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch scanner settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
// Reset when modal closes
|
||||
currentStep = 'configure';
|
||||
sourceTag = initialTag;
|
||||
targetRegistryId = null;
|
||||
customTag = '';
|
||||
pullStatus = 'idle';
|
||||
pullStarted = false;
|
||||
scanStatus = 'idle';
|
||||
scanStarted = false;
|
||||
scanResults = [];
|
||||
pushStatus = 'idle';
|
||||
pushStarted = false;
|
||||
pullTabRef?.reset();
|
||||
scanTabRef?.reset();
|
||||
pushTabRef?.reset();
|
||||
} else {
|
||||
// Set initial values when modal opens
|
||||
sourceTag = initialTag;
|
||||
// Pre-fill target tag with source image name (without registry prefix) and tag
|
||||
const imageNameOnly = imageName.includes('/') ? imageName.split('/').pop()! : imageName;
|
||||
customTag = `${imageNameOnly}:${initialTag}`;
|
||||
// Preselect registry if only one available
|
||||
if (pushableRegistries.length === 1) {
|
||||
targetRegistryId = pushableRegistries[0].id;
|
||||
}
|
||||
// Fetch scanner settings
|
||||
fetchScannerSettings();
|
||||
}
|
||||
});
|
||||
|
||||
function startCopy() {
|
||||
currentStep = 'pull';
|
||||
pullStarted = true;
|
||||
// PullTab will auto-start due to autoStart prop
|
||||
}
|
||||
|
||||
function handlePullComplete() {
|
||||
pullStatus = 'complete';
|
||||
if (envHasScanning) {
|
||||
// Go to scan step
|
||||
currentStep = 'scan';
|
||||
scanStarted = true;
|
||||
// ScanTab will auto-start
|
||||
setTimeout(() => scanTabRef?.startScan(), 100);
|
||||
} else {
|
||||
// Skip scan, go directly to push
|
||||
currentStep = 'push';
|
||||
pushStarted = true;
|
||||
// PushTab will auto-start
|
||||
setTimeout(() => pushTabRef?.startPush(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePullError(error: string) {
|
||||
pullStatus = 'error';
|
||||
}
|
||||
|
||||
function handlePullStatusChange(status: 'idle' | 'pulling' | 'complete' | 'error') {
|
||||
pullStatus = status;
|
||||
}
|
||||
|
||||
function handleScanComplete(results: ScanResult[]) {
|
||||
scanResults = results;
|
||||
// Don't auto-push - wait for user confirmation
|
||||
}
|
||||
|
||||
function handleScanError(error: string) {
|
||||
// Error is handled by ScanTab display
|
||||
}
|
||||
|
||||
function handleScanStatusChange(status: 'idle' | 'scanning' | 'complete' | 'error') {
|
||||
scanStatus = status;
|
||||
}
|
||||
|
||||
function proceedToPush() {
|
||||
currentStep = 'push';
|
||||
pushStarted = true;
|
||||
// PushTab will auto-start
|
||||
setTimeout(() => pushTabRef?.startPush(), 100);
|
||||
}
|
||||
|
||||
function handlePushComplete(_targetTag: string) {
|
||||
pushStatus = 'complete';
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
function handlePushError(_error: string) {
|
||||
pushStatus = 'error';
|
||||
}
|
||||
|
||||
function handlePushStatusChange(status: 'idle' | 'pushing' | 'complete' | 'error') {
|
||||
pushStatus = status;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (!isProcessing) {
|
||||
open = false;
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTargetToClipboard() {
|
||||
await navigator.clipboard.writeText(targetImageName());
|
||||
copiedToClipboard = true;
|
||||
setTimeout(() => copiedToClipboard = false, 2000);
|
||||
}
|
||||
|
||||
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleClose}>
|
||||
<Dialog.Content class="max-w-4xl h-[85vh] flex flex-col">
|
||||
<Dialog.Header class="shrink-0 pb-2">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
{#if pushStatus === 'complete'}
|
||||
<CheckCircle2 class="w-5 h-5 text-green-500" />
|
||||
{:else if pullStatus === 'error' || pushStatus === 'error'}
|
||||
<XCircle class="w-5 h-5 text-red-500" />
|
||||
{:else}
|
||||
<Copy class="w-5 h-5" />
|
||||
{/if}
|
||||
Copy to registry
|
||||
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{imageName}</code>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Step tabs -->
|
||||
<div class="flex items-center border-b shrink-0">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {currentStep === 'configure' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => { if (!isProcessing && currentStep !== 'configure') currentStep = 'configure'; }}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Settings2 class="w-3.5 h-3.5 inline mr-1.5" />
|
||||
Configure
|
||||
</button>
|
||||
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {currentStep === 'pull' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => { if (!isProcessing && pullStatus !== 'idle') currentStep = 'pull'; }}
|
||||
disabled={isProcessing || pullStatus === 'idle'}
|
||||
>
|
||||
<Download class="w-3.5 h-3.5 inline mr-1.5" />
|
||||
Pull
|
||||
{#if pullStatus === 'complete'}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
|
||||
{:else if pullStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
|
||||
{:else}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if envHasScanning}
|
||||
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {currentStep === 'scan' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => { if (!isProcessing && scanStatus !== 'idle') currentStep = 'scan'; }}
|
||||
disabled={isProcessing || scanStatus === 'idle'}
|
||||
>
|
||||
{#if scanStatus === 'complete' && scanResults.length > 0}
|
||||
{#if hasCriticalOrHigh}
|
||||
<ShieldX class="w-3.5 h-3.5 inline mr-1.5 text-red-500" />
|
||||
{:else if totalVulnerabilities > 0}
|
||||
<ShieldAlert class="w-3.5 h-3.5 inline mr-1.5 text-yellow-500" />
|
||||
{:else}
|
||||
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5 text-green-500" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5" />
|
||||
{/if}
|
||||
Scan
|
||||
{#if scanStatus === 'complete'}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
|
||||
{:else if scanStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
|
||||
{:else}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {currentStep === 'push' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => { if (!isProcessing && pushStatus !== 'idle') currentStep = 'push'; }}
|
||||
disabled={isProcessing || pushStatus === 'idle'}
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5 inline mr-1.5" />
|
||||
Push
|
||||
{#if pushStatus === 'complete'}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
|
||||
{:else if pushStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
|
||||
{:else}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col overflow-hidden py-4">
|
||||
<!-- Configuration Step -->
|
||||
<div class="space-y-4 px-1" class:hidden={currentStep !== 'configure'}>
|
||||
<div class="space-y-2">
|
||||
<Label>Source image</Label>
|
||||
<div class="p-2 bg-muted rounded text-sm">
|
||||
<code class="break-all">{imageName}:{sourceTag}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Target registry</Label>
|
||||
<Select.Root type="single" value={targetRegistryId ? String(targetRegistryId) : undefined} onValueChange={(v) => targetRegistryId = Number(v)}>
|
||||
<Select.Trigger class="w-full h-9 justify-start">
|
||||
{#if targetRegistry}
|
||||
{#if isDockerHub(targetRegistry)}
|
||||
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{:else}
|
||||
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{/if}
|
||||
<span class="flex-1 text-left">{targetRegistry.name}{targetRegistry.hasCredentials ? ' (auth)' : ''}</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Select registry</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each pushableRegistries as registry}
|
||||
<Select.Item value={String(registry.id)} label={registry.name}>
|
||||
{#if isDockerHub(registry)}
|
||||
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{:else}
|
||||
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{/if}
|
||||
{registry.name}
|
||||
{#if registry.hasCredentials}
|
||||
<Badge variant="outline" class="ml-2 text-xs">auth</Badge>
|
||||
{/if}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if pushableRegistries.length === 0}
|
||||
<p class="text-xs text-muted-foreground">No target registries available. Add a private registry in Settings.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Image name/tag</Label>
|
||||
<Input
|
||||
bind:value={customTag}
|
||||
placeholder="myimage:latest"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span>Will be pushed as:</span>
|
||||
<code class="bg-muted px-1 py-0.5 rounded">{targetImageName()}</code>
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyTargetToClipboard}
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors cursor-pointer"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{#if copiedToClipboard}
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
{:else}
|
||||
<Clipboard class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pull Step -->
|
||||
<div class="flex flex-col flex-1 min-h-0" class:hidden={currentStep !== 'pull'}>
|
||||
<PullTab
|
||||
bind:this={pullTabRef}
|
||||
imageName={fullSourceImageName()}
|
||||
envId={effectiveEnvId}
|
||||
showImageInput={false}
|
||||
autoStart={pullStarted && pullStatus === 'idle'}
|
||||
onComplete={handlePullComplete}
|
||||
onError={handlePullError}
|
||||
onStatusChange={handlePullStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scan Step -->
|
||||
{#if envHasScanning}
|
||||
<div class="flex flex-col flex-1 min-h-0" class:hidden={currentStep !== 'scan'}>
|
||||
<ScanTab
|
||||
bind:this={scanTabRef}
|
||||
imageName={fullSourceImageName()}
|
||||
envId={effectiveEnvId}
|
||||
autoStart={scanStarted && scanStatus === 'idle'}
|
||||
onComplete={handleScanComplete}
|
||||
onError={handleScanError}
|
||||
onStatusChange={handleScanStatusChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Push Step -->
|
||||
<div class="flex flex-col flex-1 min-h-0" class:hidden={currentStep !== 'push'}>
|
||||
<PushTab
|
||||
bind:this={pushTabRef}
|
||||
sourceImageName={fullSourceImageName()}
|
||||
registryId={targetRegistryId ?? 0}
|
||||
newTag={customTag}
|
||||
registryName={targetRegistry?.name || 'registry'}
|
||||
envId={effectiveEnvId}
|
||||
autoStart={pushStarted && pushStatus === 'idle'}
|
||||
onComplete={handlePushComplete}
|
||||
onError={handlePushError}
|
||||
onStatusChange={handlePushStatusChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0 flex justify-between">
|
||||
<div>
|
||||
{#if currentStep === 'pull' && pullStatus === 'error'}
|
||||
<Button variant="outline" onclick={() => pullTabRef?.startPull()}>
|
||||
Retry pull
|
||||
</Button>
|
||||
{:else if currentStep === 'scan' && scanStatus === 'error'}
|
||||
<Button variant="outline" onclick={() => scanTabRef?.startScan()}>
|
||||
Retry scan
|
||||
</Button>
|
||||
{:else if currentStep === 'push' && pushStatus === 'error'}
|
||||
<Button variant="outline" onclick={() => pushTabRef?.startPush()}>
|
||||
Retry push
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{pushStatus === 'complete' ? 'Done' : 'Cancel'}
|
||||
</Button>
|
||||
{#if currentStep === 'configure'}
|
||||
<Button
|
||||
onclick={startCopy}
|
||||
disabled={!targetRegistryId || pushableRegistries.length === 0}
|
||||
>
|
||||
<Copy class="w-4 h-4 mr-2" />
|
||||
Start copy
|
||||
</Button>
|
||||
{:else if currentStep === 'scan' && scanStatus === 'complete'}
|
||||
{#if hasCriticalOrHigh}
|
||||
<div class="flex items-center gap-2 text-red-600 text-sm mr-2">
|
||||
<ShieldX class="w-4 h-4" />
|
||||
<span>Critical/high vulnerabilities found</span>
|
||||
</div>
|
||||
{:else if totalVulnerabilities > 0}
|
||||
<div class="flex items-center gap-2 text-yellow-600 text-sm mr-2">
|
||||
<ShieldAlert class="w-4 h-4" />
|
||||
<span>{totalVulnerabilities} vulnerabilities found</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Button onclick={proceedToPush} variant={hasCriticalOrHigh ? 'destructive' : 'default'}>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{hasCriticalOrHigh ? 'Push anyway' : 'Continue to push'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
230
routes/registry/ImagePullModal.svelte
Normal file
230
routes/registry/ImagePullModal.svelte
Normal file
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { CheckCircle2, XCircle, Download, ShieldCheck, ShieldAlert, ShieldX, ArrowBigRight } from 'lucide-svelte';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
import PullTab from '$lib/components/PullTab.svelte';
|
||||
import ScanTab from '$lib/components/ScanTab.svelte';
|
||||
import type { ScanResult } from '$lib/components/ScanTab.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
imageName: string;
|
||||
envHasScanning?: boolean;
|
||||
envId?: number | null;
|
||||
onClose?: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), imageName, envHasScanning = false, envId, onClose, onComplete }: Props = $props();
|
||||
|
||||
// Component refs
|
||||
let pullTabRef = $state<PullTab | undefined>();
|
||||
let scanTabRef = $state<ScanTab | undefined>();
|
||||
|
||||
// Tab state
|
||||
let activeTab = $state<'pull' | 'scan'>('pull');
|
||||
|
||||
// Track status from components
|
||||
let pullStatus = $state<'idle' | 'pulling' | 'complete' | 'error'>('idle');
|
||||
let scanStatus = $state<'idle' | 'scanning' | 'complete' | 'error'>('idle');
|
||||
let scanResults = $state<ScanResult[]>([]);
|
||||
let hasStarted = $state(false);
|
||||
let pullStarted = $state(false);
|
||||
let scanStarted = $state(false);
|
||||
let autoSwitchedToScan = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && imageName && !hasStarted) {
|
||||
hasStarted = true;
|
||||
pullStarted = true;
|
||||
}
|
||||
if (!open && hasStarted) {
|
||||
// Reset when modal closes
|
||||
hasStarted = false;
|
||||
pullStarted = false;
|
||||
scanStarted = false;
|
||||
pullStatus = 'idle';
|
||||
scanStatus = 'idle';
|
||||
scanResults = [];
|
||||
activeTab = 'pull';
|
||||
autoSwitchedToScan = false;
|
||||
pullTabRef?.reset();
|
||||
scanTabRef?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
function handlePullComplete() {
|
||||
pullStatus = 'complete';
|
||||
if (envHasScanning && !autoSwitchedToScan) {
|
||||
autoSwitchedToScan = true;
|
||||
scanStarted = true;
|
||||
activeTab = 'scan';
|
||||
setTimeout(() => scanTabRef?.startScan(), 100);
|
||||
} else {
|
||||
onComplete?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePullError(_error: string) {
|
||||
pullStatus = 'error';
|
||||
}
|
||||
|
||||
function handlePullStatusChange(status: 'idle' | 'pulling' | 'complete' | 'error') {
|
||||
pullStatus = status;
|
||||
}
|
||||
|
||||
function handleScanComplete(results: ScanResult[]) {
|
||||
scanResults = results;
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
function handleScanError(_error: string) {
|
||||
// Error is handled by ScanTab display
|
||||
}
|
||||
|
||||
function handleScanStatusChange(status: 'idle' | 'scanning' | 'complete' | 'error') {
|
||||
scanStatus = status;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (pullStatus !== 'pulling' && scanStatus !== 'scanning') {
|
||||
open = false;
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
const totalVulnerabilities = $derived(
|
||||
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
|
||||
);
|
||||
|
||||
const hasCriticalOrHigh = $derived(
|
||||
scanResults.some(r => r.summary.critical > 0 || r.summary.high > 0)
|
||||
);
|
||||
|
||||
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning');
|
||||
|
||||
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
|
||||
|
||||
const title = $derived(envHasScanning ? 'Pull & scan image' : 'Pull image');
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleClose}>
|
||||
<Dialog.Content class="max-w-4xl h-[85vh] flex flex-col">
|
||||
<Dialog.Header class="shrink-0 pb-2">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
{#if scanStatus === 'complete' && scanResults.length > 0}
|
||||
{#if hasCriticalOrHigh}
|
||||
<ShieldX class="w-5 h-5 text-red-500" />
|
||||
{:else if totalVulnerabilities > 0}
|
||||
<ShieldAlert class="w-5 h-5 text-yellow-500" />
|
||||
{:else}
|
||||
<ShieldCheck class="w-5 h-5 text-green-500" />
|
||||
{/if}
|
||||
{:else if pullStatus === 'complete' && !envHasScanning}
|
||||
<CheckCircle2 class="w-5 h-5 text-green-500" />
|
||||
{:else if pullStatus === 'error' || scanStatus === 'error'}
|
||||
<XCircle class="w-5 h-5 text-red-500" />
|
||||
{:else}
|
||||
<Download class="w-5 h-5" />
|
||||
{/if}
|
||||
{title}
|
||||
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{imageName}</code>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Tabs -->
|
||||
{#if envHasScanning}
|
||||
<div class="flex items-center border-b shrink-0">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'pull' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => activeTab = 'pull'}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Download class="w-3.5 h-3.5 inline mr-1.5" />
|
||||
Pull
|
||||
{#if pullStatus === 'complete'}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
|
||||
{:else if pullStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
|
||||
{:else}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
|
||||
{/if}
|
||||
</button>
|
||||
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'scan' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => activeTab = 'scan'}
|
||||
disabled={isProcessing || pullStatus !== 'complete'}
|
||||
>
|
||||
{#if scanStatus === 'complete' && scanResults.length > 0}
|
||||
{#if hasCriticalOrHigh}
|
||||
<ShieldX class="w-3.5 h-3.5 inline mr-1.5 text-red-500" />
|
||||
{:else if totalVulnerabilities > 0}
|
||||
<ShieldAlert class="w-3.5 h-3.5 inline mr-1.5 text-yellow-500" />
|
||||
{:else}
|
||||
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5 text-green-500" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5" />
|
||||
{/if}
|
||||
Scan
|
||||
{#if scanStatus === 'complete'}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
|
||||
{:else if scanStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
|
||||
{:else}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col overflow-hidden py-2">
|
||||
<!-- Pull Tab -->
|
||||
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'pull'}>
|
||||
<PullTab
|
||||
bind:this={pullTabRef}
|
||||
{imageName}
|
||||
envId={effectiveEnvId}
|
||||
showImageInput={false}
|
||||
autoStart={pullStarted && pullStatus === 'idle'}
|
||||
onComplete={handlePullComplete}
|
||||
onError={handlePullError}
|
||||
onStatusChange={handlePullStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scan Tab -->
|
||||
{#if envHasScanning}
|
||||
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'scan'}>
|
||||
<ScanTab
|
||||
bind:this={scanTabRef}
|
||||
{imageName}
|
||||
envId={effectiveEnvId}
|
||||
autoStart={scanStarted && scanStatus === 'idle'}
|
||||
onComplete={handleScanComplete}
|
||||
onError={handleScanError}
|
||||
onStatusChange={handleScanStatusChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0 flex justify-end">
|
||||
<Button
|
||||
variant={pullStatus === 'complete' || scanStatus === 'complete' ? 'default' : 'secondary'}
|
||||
onclick={handleClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{#if pullStatus === 'pulling'}
|
||||
Pulling...
|
||||
{:else if scanStatus === 'scanning'}
|
||||
Scanning...
|
||||
{:else}
|
||||
Close
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user