mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
808 lines
24 KiB
Svelte
808 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
import * as Dialog from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { ArrowBigRight, Settings2, Shield, Loader2, Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, AlertTriangle, X, Play } from 'lucide-svelte';
|
|
import { toast } from 'svelte-sonner';
|
|
import { currentEnvironment } from '$lib/stores/environment';
|
|
import { focusFirstInput } from '$lib/utils';
|
|
import PullTab from '$lib/components/PullTab.svelte';
|
|
import ScanTab from '$lib/components/ScanTab.svelte';
|
|
import type { ScanResult } from '$lib/components/ScanTab.svelte';
|
|
import ContainerSettingsTab from './ContainerSettingsTab.svelte';
|
|
import { parseHostPort, expandPortBindings } from '$lib/utils/port-parse';
|
|
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
|
|
|
|
// Parse shell command respecting quotes
|
|
function parseShellCommand(cmd: string): string[] {
|
|
const args: string[] = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
let quoteChar = '';
|
|
|
|
for (let i = 0; i < cmd.length; i++) {
|
|
const char = cmd[i];
|
|
|
|
if ((char === '"' || char === "'") && !inQuotes) {
|
|
inQuotes = true;
|
|
quoteChar = char;
|
|
} else if (char === quoteChar && inQuotes) {
|
|
inQuotes = false;
|
|
quoteChar = '';
|
|
} else if (char === ' ' && !inQuotes) {
|
|
if (current) {
|
|
args.push(current);
|
|
current = '';
|
|
}
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
args.push(current);
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
interface ConfigSet {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
envVars?: { key: string; value: string }[];
|
|
labels?: { key: string; value: string }[];
|
|
ports?: { hostPort: string; containerPort: string; protocol: string }[];
|
|
volumes?: { hostPath: string; containerPath: string; mode: string }[];
|
|
networkMode: string;
|
|
restartPolicy: string;
|
|
}
|
|
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose?: () => void;
|
|
onSuccess?: () => void;
|
|
prefilledImage?: string;
|
|
skipPullTab?: boolean;
|
|
autoPull?: boolean;
|
|
}
|
|
|
|
let { open = $bindable(), onClose, onSuccess, prefilledImage, skipPullTab = false, autoPull = false }: Props = $props();
|
|
|
|
// Track if we've already auto-pulled for this modal session
|
|
let hasAutoPulled = $state(false);
|
|
|
|
// Tab state - start on settings if skipping pull tab
|
|
let activeTab = $state<'pull' | 'scan' | 'container'>(skipPullTab ? 'container' : 'pull');
|
|
|
|
// Config sets
|
|
let configSets = $state<ConfigSet[]>([]);
|
|
let selectedConfigSetId = $state<string>('');
|
|
|
|
// Form state
|
|
let name = $state('');
|
|
let image = $state('');
|
|
let command = $state('');
|
|
let restartPolicy = $state('no');
|
|
let restartMaxRetries = $state<number | ''>('');
|
|
let networkMode = $state('bridge');
|
|
let startAfterCreate = $state(true);
|
|
|
|
// Port mappings
|
|
let portMappings = $state<{ hostPort: string; containerPort: string; protocol: string }[]>([
|
|
{ hostPort: '', containerPort: '', protocol: 'tcp' }
|
|
]);
|
|
|
|
// Volume mappings
|
|
let volumeMappings = $state<{ hostPath: string; containerPath: string; mode: string }[]>([
|
|
{ hostPath: '', containerPath: '', mode: 'rw' }
|
|
]);
|
|
|
|
// Environment variables
|
|
let envVars = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
|
|
|
|
// Labels
|
|
let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
|
|
|
|
// Networks
|
|
let selectedNetworks = $state<string[]>([]);
|
|
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
|
|
let macAddress = $state('');
|
|
|
|
// Auto-update settings
|
|
let autoUpdateEnabled = $state(false);
|
|
let autoUpdateCronExpression = $state('0 3 * * *');
|
|
let vulnerabilityCriteria = $state<VulnerabilityCriteria>('never');
|
|
|
|
|
|
// User/Group
|
|
let containerUser = $state('');
|
|
|
|
// Privileged mode
|
|
let privilegedMode = $state(false);
|
|
|
|
// Healthcheck settings
|
|
let healthcheckEnabled = $state(false);
|
|
let healthcheckCommand = $state('');
|
|
let healthcheckInterval = $state(30);
|
|
let healthcheckTimeout = $state(30);
|
|
let healthcheckRetries = $state(3);
|
|
let healthcheckStartPeriod = $state(0);
|
|
|
|
// Resource limits
|
|
let memoryLimit = $state('');
|
|
let memoryReservation = $state('');
|
|
let cpuShares = $state('');
|
|
let nanoCpus = $state('');
|
|
let cpuQuota = $state('');
|
|
let cpuPeriod = $state('');
|
|
|
|
// Capabilities
|
|
let capAdd = $state<string[]>([]);
|
|
let capDrop = $state<string[]>([]);
|
|
|
|
// Devices
|
|
let deviceMappings = $state<{ hostPath: string; containerPath: string; permissions: string }[]>([]);
|
|
|
|
// DNS settings
|
|
let dnsServers = $state<string[]>([]);
|
|
let dnsSearch = $state<string[]>([]);
|
|
let dnsOptions = $state<string[]>([]);
|
|
|
|
// Security options
|
|
let securityOptions = $state<string[]>([]);
|
|
|
|
// Ulimits
|
|
let ulimits = $state<{ name: string; soft: string; hard: string }[]>([]);
|
|
|
|
// GPU settings
|
|
let gpuEnabled = $state(false);
|
|
let gpuMode = $state<'all' | 'count' | 'specific'>('all');
|
|
let gpuCount = $state(1);
|
|
let gpuDeviceIds = $state<string[]>([]);
|
|
let gpuDriver = $state('');
|
|
let gpuCapabilities = $state<string[]>(['gpu']);
|
|
let runtime = $state('');
|
|
|
|
let loading = $state(false);
|
|
let errors = $state<{ name?: string; image?: string }>({});
|
|
|
|
// Component refs
|
|
let pullTabRef: PullTab | undefined;
|
|
let scanTabRef: ScanTab | undefined;
|
|
|
|
// Pull & Scan status (tracked via component callbacks)
|
|
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[]>([]);
|
|
|
|
// Scanner settings - scanning is enabled if a scanner is configured
|
|
let envHasScanning = $state(false);
|
|
|
|
// Fetch config sets and networks when modal opens
|
|
$effect(() => {
|
|
if (open) {
|
|
fetchConfigSets();
|
|
fetchScannerSettings();
|
|
}
|
|
});
|
|
|
|
// Track previous open state to detect when modal opens
|
|
let wasOpen = $state(false);
|
|
|
|
$effect(() => {
|
|
if (open && !wasOpen) {
|
|
// Modal just opened - reset state
|
|
pullStatus = 'idle';
|
|
pullStarted = !skipPullTab;
|
|
scanStatus = 'idle';
|
|
scanStarted = false;
|
|
scanResults = [];
|
|
hasAutoPulled = false;
|
|
autoSwitchedToScan = false;
|
|
autoSwitchedToContainer = false;
|
|
activeTab = skipPullTab ? 'container' : 'pull';
|
|
|
|
// Reset components
|
|
pullTabRef?.reset();
|
|
scanTabRef?.reset();
|
|
|
|
// Set image from prefilledImage if provided
|
|
if (prefilledImage) {
|
|
image = prefilledImage;
|
|
}
|
|
}
|
|
wasOpen = open;
|
|
});
|
|
|
|
// Auto-pull when autoPull is true and modal opens with an image
|
|
$effect(() => {
|
|
if (autoPull && open && prefilledImage && !hasAutoPulled && pullStatus === 'idle') {
|
|
hasAutoPulled = true;
|
|
// Small delay to ensure component is mounted
|
|
setTimeout(() => pullTabRef?.startPull(), 100);
|
|
}
|
|
});
|
|
|
|
// Track auto-switch state to prevent re-triggering when user manually clicks tabs
|
|
let autoSwitchedToScan = $state(false);
|
|
let autoSwitchedToContainer = $state(false);
|
|
|
|
// Handle pull completion
|
|
function handlePullComplete() {
|
|
pullStatus = 'complete';
|
|
// Auto-start scan if enabled
|
|
if (envHasScanning && !autoSwitchedToScan) {
|
|
autoSwitchedToScan = true;
|
|
scanStarted = true;
|
|
activeTab = 'scan';
|
|
// Start scan with small delay for tab switch
|
|
setTimeout(() => scanTabRef?.startScan(), 100);
|
|
} else if (!envHasScanning && !autoSwitchedToContainer) {
|
|
// Go to container tab if no scan
|
|
autoSwitchedToContainer = true;
|
|
activeTab = 'container';
|
|
}
|
|
}
|
|
|
|
function handlePullError(error: string) {
|
|
pullStatus = 'error';
|
|
}
|
|
|
|
function handlePullStatusChange(status: 'idle' | 'pulling' | 'complete' | 'error') {
|
|
pullStatus = status;
|
|
}
|
|
|
|
function handleScanComplete(results: ScanResult[]) {
|
|
scanStatus = 'complete';
|
|
scanResults = results;
|
|
// Auto-switch to container tab
|
|
if (!autoSwitchedToContainer) {
|
|
autoSwitchedToContainer = true;
|
|
activeTab = 'container';
|
|
}
|
|
}
|
|
|
|
function handleScanError(error: string) {
|
|
scanStatus = 'error';
|
|
}
|
|
|
|
function handleScanStatusChange(status: 'idle' | 'scanning' | 'complete' | 'error') {
|
|
scanStatus = status;
|
|
}
|
|
|
|
async function fetchConfigSets() {
|
|
try {
|
|
const response = await fetch('/api/config-sets');
|
|
if (response.ok) {
|
|
configSets = await response.json();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch config sets:', err);
|
|
}
|
|
}
|
|
|
|
async function fetchScannerSettings() {
|
|
try {
|
|
const envId = $currentEnvironment?.id;
|
|
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 (err) {
|
|
console.error('Failed to fetch scanner settings:', err);
|
|
}
|
|
}
|
|
|
|
|
|
function parseMemory(value: string): number | undefined {
|
|
if (!value) return undefined;
|
|
const match = value.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?b?)?$/);
|
|
if (!match) return undefined;
|
|
const num = parseFloat(match[1]);
|
|
const unit = match[2] || '';
|
|
switch (unit) {
|
|
case 'k': case 'kb': return Math.floor(num * 1024);
|
|
case 'm': case 'mb': return Math.floor(num * 1024 * 1024);
|
|
case 'g': case 'gb': return Math.floor(num * 1024 * 1024 * 1024);
|
|
case 't': case 'tb': return Math.floor(num * 1024 * 1024 * 1024 * 1024);
|
|
default: return Math.floor(num);
|
|
}
|
|
}
|
|
|
|
function parseNanoCpus(value: string): number | undefined {
|
|
if (!value) return undefined;
|
|
const num = parseFloat(value);
|
|
if (isNaN(num)) return undefined;
|
|
return Math.floor(num * 1e9);
|
|
}
|
|
|
|
async function handleSubmit(e: Event) {
|
|
e.preventDefault();
|
|
errors = {};
|
|
|
|
let hasErrors = false;
|
|
if (!name.trim()) {
|
|
errors.name = 'Container name is required';
|
|
hasErrors = true;
|
|
}
|
|
|
|
if (!image.trim()) {
|
|
errors.image = 'Image name is required';
|
|
hasErrors = true;
|
|
}
|
|
|
|
if (hasErrors) {
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
|
|
try {
|
|
const ports: Record<string, { HostIp?: string; HostPort: string }> = {};
|
|
portMappings
|
|
.filter((p) => p.containerPort)
|
|
.forEach((p) => {
|
|
const parsed = parseHostPort(p.hostPort);
|
|
const bindings = expandPortBindings(parsed.hostPort, p.containerPort, p.protocol, parsed.hostIp);
|
|
Object.assign(ports, bindings);
|
|
});
|
|
|
|
const volumeBinds = volumeMappings
|
|
.filter((v) => v.hostPath && v.containerPath)
|
|
.map((v) => `${v.hostPath}:${v.containerPath}:${v.mode}`);
|
|
|
|
const env = envVars
|
|
.filter((e) => e.key && e.value)
|
|
.map((e) => `${e.key}=${e.value}`);
|
|
|
|
const labelsObj: any = {};
|
|
labels
|
|
.filter((l) => l.key && l.value)
|
|
.forEach((l) => {
|
|
labelsObj[l.key] = l.value;
|
|
});
|
|
|
|
const cmd = command.trim() ? parseShellCommand(command.trim()) : undefined;
|
|
|
|
let healthcheck: any = undefined;
|
|
if (healthcheckEnabled && healthcheckCommand.trim()) {
|
|
healthcheck = {
|
|
test: ['CMD-SHELL', healthcheckCommand.trim()],
|
|
interval: healthcheckInterval * 1e9,
|
|
timeout: healthcheckTimeout * 1e9,
|
|
retries: healthcheckRetries,
|
|
startPeriod: healthcheckStartPeriod * 1e9
|
|
};
|
|
}
|
|
|
|
const devices = deviceMappings
|
|
.filter(d => d.hostPath && d.containerPath)
|
|
.map(d => ({
|
|
hostPath: d.hostPath,
|
|
containerPath: d.containerPath,
|
|
permissions: d.permissions || 'rwm'
|
|
}));
|
|
|
|
const ulimitsArray = ulimits
|
|
.filter(u => u.name && u.soft && u.hard)
|
|
.map(u => ({
|
|
name: u.name,
|
|
soft: parseInt(u.soft) || 0,
|
|
hard: parseInt(u.hard) || 0
|
|
}));
|
|
|
|
let deviceRequests: any[] | undefined = undefined;
|
|
if (gpuEnabled) {
|
|
const dr: any = {
|
|
capabilities: gpuCapabilities.length > 0 ? [gpuCapabilities] : [['gpu']],
|
|
driver: gpuDriver || undefined
|
|
};
|
|
if (gpuMode === 'all') {
|
|
dr.count = -1;
|
|
} else if (gpuMode === 'count') {
|
|
dr.count = gpuCount || 1;
|
|
} else {
|
|
dr.count = 0;
|
|
dr.deviceIDs = gpuDeviceIds.filter(id => id.trim());
|
|
}
|
|
deviceRequests = [dr];
|
|
}
|
|
|
|
// Build per-network configs for the API
|
|
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
|
|
for (const [netName, cfg] of Object.entries(networkConfigs)) {
|
|
if (!selectedNetworks.includes(netName)) continue;
|
|
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
|
|
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
|
|
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
|
|
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
|
|
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
|
|
}
|
|
|
|
const payload = {
|
|
name: name.trim(),
|
|
image: image.trim(),
|
|
ports: Object.keys(ports).length > 0 ? ports : undefined,
|
|
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
|
|
env: env.length > 0 ? env : undefined,
|
|
labels: Object.keys(labelsObj).length > 0 ? labelsObj : undefined,
|
|
cmd,
|
|
restartPolicy,
|
|
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
|
|
networkMode,
|
|
additionalNetworks: (() => {
|
|
const isSharedMode = networkMode === 'host' || networkMode === 'none' || networkMode.startsWith('container:');
|
|
if (isSharedMode) return undefined;
|
|
return selectedNetworks.length > 0 ? selectedNetworks : undefined;
|
|
})(),
|
|
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
|
|
macAddress: macAddress.trim() || undefined,
|
|
startAfterCreate,
|
|
user: containerUser.trim() || undefined,
|
|
privileged: privilegedMode || undefined,
|
|
healthcheck,
|
|
memory: parseMemory(memoryLimit),
|
|
memoryReservation: parseMemory(memoryReservation),
|
|
cpuShares: cpuShares ? parseInt(cpuShares) : undefined,
|
|
nanoCpus: parseNanoCpus(nanoCpus),
|
|
cpuQuota: cpuQuota ? parseInt(cpuQuota) : undefined,
|
|
cpuPeriod: cpuPeriod ? parseInt(cpuPeriod) : undefined,
|
|
capAdd: capAdd.length > 0 ? capAdd : undefined,
|
|
capDrop: capDrop.length > 0 ? capDrop : undefined,
|
|
devices: devices.length > 0 ? devices : undefined,
|
|
deviceRequests,
|
|
runtime: runtime || undefined,
|
|
dns: dnsServers.length > 0 ? dnsServers : undefined,
|
|
dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined,
|
|
dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined,
|
|
securityOpt: securityOptions.length > 0 ? securityOptions : undefined,
|
|
ulimits: ulimitsArray.length > 0 ? ulimitsArray : undefined
|
|
};
|
|
|
|
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
|
|
const response = await fetch(`/api/containers${envParam}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (!response.ok) {
|
|
let errorMsg = result.error || 'Failed to create container';
|
|
if (result.details) {
|
|
errorMsg += ': ' + result.details;
|
|
}
|
|
toast.error(errorMsg);
|
|
return;
|
|
}
|
|
|
|
if (autoUpdateEnabled) {
|
|
try {
|
|
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
|
|
await fetch(`/api/auto-update/${encodeURIComponent(name.trim())}${envParam}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
enabled: autoUpdateEnabled,
|
|
cronExpression: autoUpdateCronExpression,
|
|
vulnerabilityCriteria: vulnerabilityCriteria
|
|
})
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to save auto-update settings:', err);
|
|
}
|
|
}
|
|
|
|
if (result.imagePulled) {
|
|
toast.success(`Container created (image ${image.trim()} was pulled automatically)`);
|
|
} else {
|
|
toast.success('Container created successfully');
|
|
}
|
|
|
|
open = false;
|
|
resetForm();
|
|
onSuccess?.();
|
|
onClose?.();
|
|
} catch (err) {
|
|
toast.error('Failed to create container: ' + String(err));
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
name = '';
|
|
image = '';
|
|
command = '';
|
|
restartPolicy = 'no';
|
|
restartMaxRetries = '';
|
|
networkMode = 'bridge';
|
|
startAfterCreate = true;
|
|
portMappings = [{ hostPort: '', containerPort: '', protocol: 'tcp' }];
|
|
volumeMappings = [{ hostPath: '', containerPath: '', mode: 'rw' }];
|
|
envVars = [{ key: '', value: '' }];
|
|
labels = [{ key: '', value: '' }];
|
|
selectedNetworks = [];
|
|
networkConfigs = {};
|
|
macAddress = '';
|
|
autoUpdateEnabled = false;
|
|
autoUpdateCronExpression = '0 3 * * *';
|
|
vulnerabilityCriteria = 'never';
|
|
errors = {};
|
|
selectedConfigSetId = '';
|
|
containerUser = '';
|
|
privilegedMode = false;
|
|
healthcheckEnabled = false;
|
|
healthcheckCommand = '';
|
|
healthcheckInterval = 30;
|
|
healthcheckTimeout = 30;
|
|
healthcheckRetries = 3;
|
|
healthcheckStartPeriod = 0;
|
|
memoryLimit = '';
|
|
memoryReservation = '';
|
|
cpuShares = '';
|
|
nanoCpus = '';
|
|
cpuQuota = '';
|
|
cpuPeriod = '';
|
|
capAdd = [];
|
|
capDrop = [];
|
|
deviceMappings = [];
|
|
dnsServers = [];
|
|
dnsSearch = [];
|
|
dnsOptions = [];
|
|
securityOptions = [];
|
|
ulimits = [];
|
|
gpuEnabled = false;
|
|
gpuMode = 'all';
|
|
gpuCount = 1;
|
|
gpuDeviceIds = [];
|
|
gpuDriver = '';
|
|
gpuCapabilities = ['gpu'];
|
|
runtime = '';
|
|
// Reset pull/scan state
|
|
activeTab = skipPullTab ? 'container' : 'pull';
|
|
pullStatus = 'idle';
|
|
pullStarted = false;
|
|
scanStatus = 'idle';
|
|
scanStarted = false;
|
|
scanResults = [];
|
|
hasAutoPulled = false;
|
|
autoSwitchedToScan = false;
|
|
autoSwitchedToContainer = false;
|
|
// Reset components
|
|
pullTabRef?.reset();
|
|
scanTabRef?.reset();
|
|
}
|
|
|
|
function handleClose() {
|
|
open = false;
|
|
resetForm();
|
|
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 isPulling = $derived(pullStatus === 'pulling');
|
|
const isScanning = $derived(scanStatus === 'scanning');
|
|
const imageReady = $derived(pullStatus === 'complete');
|
|
</script>
|
|
|
|
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
|
|
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
|
|
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
|
|
<Dialog.Title class="text-base font-semibold">
|
|
Create new container
|
|
{#if $currentEnvironment}
|
|
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
|
|
{/if}
|
|
</Dialog.Title>
|
|
<button
|
|
type="button"
|
|
onclick={handleClose}
|
|
disabled={loading || isPulling || isScanning}
|
|
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-30"
|
|
>
|
|
<X class="h-4 w-4" />
|
|
<span class="sr-only">Close</span>
|
|
</button>
|
|
</Dialog.Header>
|
|
|
|
<!-- Tabs (hidden when skipPullTab) -->
|
|
{#if !skipPullTab}
|
|
<div class="flex items-center border-b shrink-0 px-5 bg-muted/10">
|
|
<!-- Pull Tab -->
|
|
<button
|
|
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer flex items-center gap-2 {activeTab === 'pull' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
onclick={() => activeTab = 'pull'}
|
|
>
|
|
<Download class="w-4 h-4" />
|
|
Pull
|
|
{#if pullStatus === 'complete'}
|
|
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
|
|
{:else if pullStatus === 'pulling'}
|
|
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
|
{:else if pullStatus === 'error'}
|
|
<XCircle class="w-3.5 h-3.5 text-red-500" />
|
|
{/if}
|
|
</button>
|
|
{#if envHasScanning}
|
|
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
|
<!-- Scan Tab -->
|
|
<button
|
|
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer flex items-center gap-2 {activeTab === 'scan' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
onclick={() => activeTab = 'scan'}
|
|
disabled={pullStatus === 'idle' || pullStatus === 'pulling'}
|
|
>
|
|
<Shield class="w-4 h-4" />
|
|
Scan
|
|
{#if scanStatus === 'complete' && scanResults.length > 0}
|
|
{#if hasCriticalOrHigh}
|
|
<ShieldX class="w-3.5 h-3.5 text-red-500" />
|
|
{:else if totalVulnerabilities > 0}
|
|
<ShieldAlert class="w-3.5 h-3.5 text-yellow-500" />
|
|
{:else}
|
|
<ShieldCheck class="w-3.5 h-3.5 text-green-500" />
|
|
{/if}
|
|
{:else if scanStatus === 'scanning'}
|
|
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
|
<!-- Container Tab -->
|
|
<button
|
|
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer flex items-center gap-2 {activeTab === 'container' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
onclick={() => activeTab = 'container'}
|
|
>
|
|
<Settings2 class="w-4 h-4" />
|
|
Container
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Tab Content -->
|
|
<!-- Pull Tab - using PullTab component -->
|
|
<div class="px-5 py-4 flex-1 min-h-0 flex flex-col" class:hidden={activeTab !== 'pull'}>
|
|
<PullTab
|
|
bind:this={pullTabRef}
|
|
imageName={image}
|
|
envId={$currentEnvironment?.id}
|
|
showImageInput={!autoPull}
|
|
autoStart={pullStarted && pullStatus === 'idle' && !!prefilledImage}
|
|
onComplete={handlePullComplete}
|
|
onError={handlePullError}
|
|
onStatusChange={handlePullStatusChange}
|
|
onImageChange={(newImage) => image = newImage}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Scan Tab - using ScanTab component -->
|
|
<div class="px-5 py-4 flex-1 min-h-0 flex flex-col" class:hidden={activeTab !== 'scan'}>
|
|
{#if envHasScanning}
|
|
<ScanTab
|
|
bind:this={scanTabRef}
|
|
imageName={image}
|
|
envId={$currentEnvironment?.id}
|
|
autoStart={scanStarted && scanStatus === 'idle'}
|
|
onComplete={handleScanComplete}
|
|
onError={handleScanError}
|
|
onStatusChange={handleScanStatusChange}
|
|
/>
|
|
{:else}
|
|
<!-- Scanning disabled -->
|
|
<div class="flex-1 flex items-center justify-center">
|
|
<div class="text-center">
|
|
<Shield class="w-12 h-12 text-muted-foreground/50 mx-auto mb-2" />
|
|
<p class="text-sm text-muted-foreground">Vulnerability scanning is disabled for this environment.</p>
|
|
<p class="text-xs text-muted-foreground mt-1">Enable it in Settings -> Environments to scan images.</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Container Settings Tab -->
|
|
<div class="px-5 py-4 flex-1 overflow-y-auto" class:hidden={activeTab !== 'container'}>
|
|
<ContainerSettingsTab
|
|
mode="create"
|
|
bind:name
|
|
bind:image
|
|
bind:command
|
|
bind:restartPolicy
|
|
bind:restartMaxRetries
|
|
bind:networkMode
|
|
bind:startAfterCreate
|
|
bind:portMappings
|
|
bind:volumeMappings
|
|
bind:envVars
|
|
bind:labels
|
|
bind:selectedNetworks
|
|
bind:networkConfigs
|
|
bind:macAddress
|
|
bind:containerUser
|
|
bind:privilegedMode
|
|
bind:healthcheckEnabled
|
|
bind:healthcheckCommand
|
|
bind:healthcheckInterval
|
|
bind:healthcheckTimeout
|
|
bind:healthcheckRetries
|
|
bind:healthcheckStartPeriod
|
|
bind:memoryLimit
|
|
bind:memoryReservation
|
|
bind:cpuShares
|
|
bind:nanoCpus
|
|
bind:cpuQuota
|
|
bind:cpuPeriod
|
|
bind:capAdd
|
|
bind:capDrop
|
|
bind:securityOptions
|
|
bind:deviceMappings
|
|
bind:gpuEnabled
|
|
bind:gpuMode
|
|
bind:gpuCount
|
|
bind:gpuDeviceIds
|
|
bind:gpuDriver
|
|
bind:gpuCapabilities
|
|
bind:runtime
|
|
bind:dnsServers
|
|
bind:dnsSearch
|
|
bind:dnsOptions
|
|
bind:ulimits
|
|
bind:autoUpdateEnabled
|
|
bind:autoUpdateCronExpression
|
|
bind:vulnerabilityCriteria
|
|
{configSets}
|
|
bind:selectedConfigSetId
|
|
bind:errors
|
|
imageSummary={{
|
|
isPulling,
|
|
isScanning,
|
|
imageReady,
|
|
scanResults,
|
|
totalVulnerabilities,
|
|
hasCriticalOrHigh
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex justify-between gap-2 px-5 py-3 border-t bg-muted/30 shrink-0">
|
|
<div>
|
|
{#if activeTab === 'container' && hasCriticalOrHigh}
|
|
<div class="flex items-center gap-2 text-amber-600 text-xs">
|
|
<AlertTriangle class="w-4 h-4" />
|
|
<span>Critical/high vulnerabilities found in image</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<Button type="button" variant="outline" onclick={handleClose} disabled={loading || isPulling || isScanning}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="button" disabled={loading || isPulling || isScanning || activeTab !== 'container'} onclick={handleSubmit}>
|
|
{#if loading}
|
|
<Loader2 class="w-4 h-4 animate-spin" />
|
|
Creating...
|
|
{:else}
|
|
<Play class="w-4 h-4" />
|
|
Create container
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|