mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-08 21:29:05 +00:00
Initial commit
This commit is contained in:
2173
routes/containers/+page.svelte
Normal file
2173
routes/containers/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
81
routes/containers/AutoUpdateSettings.svelte
Normal file
81
routes/containers/AutoUpdateSettings.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
vulnerabilityCriteria: VulnerabilityCriteria;
|
||||
onenablechange?: (enabled: boolean) => void;
|
||||
oncronchange?: (cron: string) => void;
|
||||
oncriteriachange?: (criteria: VulnerabilityCriteria) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
enabled = $bindable(),
|
||||
cronExpression = $bindable(),
|
||||
vulnerabilityCriteria = $bindable(),
|
||||
onenablechange,
|
||||
oncronchange,
|
||||
oncriteriachange
|
||||
}: Props = $props();
|
||||
|
||||
let envHasScanning = $state(false);
|
||||
|
||||
// Check if environment has scanning enabled
|
||||
$effect(() => {
|
||||
if (enabled) {
|
||||
checkScannerSettings();
|
||||
}
|
||||
});
|
||||
|
||||
async function checkScannerSettings() {
|
||||
try {
|
||||
const envParam = $currentEnvironment ? `env=${$currentEnvironment.id}&` : '';
|
||||
const response = await fetch(`/api/settings/scanner?${envParam}settingsOnly=true`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
envHasScanning = data.settings.scanner !== 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch scanner settings:', err);
|
||||
envHasScanning = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs font-normal">Enable automatic image updates</Label>
|
||||
<TogglePill
|
||||
bind:checked={enabled}
|
||||
onchange={(value) => onenablechange?.(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if enabled}
|
||||
<CronEditor
|
||||
value={cronExpression}
|
||||
onchange={(cron) => {
|
||||
cronExpression = cron;
|
||||
oncronchange?.(cron);
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if envHasScanning}
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-medium">Vulnerability criteria</Label>
|
||||
<VulnerabilityCriteriaSelector
|
||||
bind:value={vulnerabilityCriteria}
|
||||
onchange={(v) => oncriteriachange?.(v)}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Block auto-updates if new image has vulnerabilities matching this criteria
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
604
routes/containers/BatchUpdateModal.svelte
Normal file
604
routes/containers/BatchUpdateModal.svelte
Normal file
@@ -0,0 +1,604 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import type { VulnerabilityCriteria } from '$lib/server/db';
|
||||
import type { StepType } from '$lib/utils/update-steps';
|
||||
import { getStepLabel, getStepIcon, getStepColor } from '$lib/utils/update-steps';
|
||||
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
|
||||
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
|
||||
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
containerIds: string[];
|
||||
containerNames: Map<string, string>;
|
||||
envId: number | null;
|
||||
vulnerabilityCriteria?: VulnerabilityCriteria;
|
||||
onClose: () => void;
|
||||
onComplete: (results: { success: string[]; failed: string[]; blocked: string[] }) => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), containerIds, containerNames, envId, vulnerabilityCriteria = 'never', onClose, onComplete }: Props = $props();
|
||||
|
||||
interface PullLogEntry {
|
||||
status: string;
|
||||
id?: string;
|
||||
progress?: string;
|
||||
}
|
||||
|
||||
interface ScanLogEntry {
|
||||
scanner?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
negligible?: number;
|
||||
unknown?: number;
|
||||
}
|
||||
|
||||
interface ScannerResult extends ScanResult {
|
||||
scanner: 'grype' | 'trivy';
|
||||
}
|
||||
|
||||
interface ContainerProgress {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
step: StepType;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
pullLogs: PullLogEntry[];
|
||||
scanLogs: ScanLogEntry[];
|
||||
scanResult?: ScanResult;
|
||||
scannerResults?: ScannerResult[];
|
||||
blockReason?: string;
|
||||
showLogs: boolean;
|
||||
}
|
||||
|
||||
let status = $state<'idle' | 'updating' | 'complete' | 'error'>('idle');
|
||||
let progress = $state<ContainerProgress[]>([]);
|
||||
let currentIndex = $state(0);
|
||||
let totalCount = $state(0);
|
||||
let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null);
|
||||
let errorMessage = $state('');
|
||||
let forceUpdating = $state<Set<string>>(new Set()); // Track containers being force-updated
|
||||
|
||||
function formatPullLog(entry: PullLogEntry): string {
|
||||
// Clarify potentially confusing Docker messages
|
||||
let status = entry.status;
|
||||
if (status.toLowerCase().includes('image is up to date')) {
|
||||
status = 'Image cached (registry version matches local)';
|
||||
} else if (status.toLowerCase().includes('status: image is up to date')) {
|
||||
status = 'Image cached (registry version matches local)';
|
||||
}
|
||||
|
||||
if (entry.id && entry.progress) {
|
||||
return `${entry.id}: ${status} ${entry.progress}`;
|
||||
}
|
||||
if (entry.id) {
|
||||
return `${entry.id}: ${status}`;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function formatScanLog(entry: ScanLogEntry): string {
|
||||
if (entry.scanner) {
|
||||
return `[${entry.scanner}] ${entry.message}`;
|
||||
}
|
||||
return entry.message;
|
||||
}
|
||||
|
||||
async function startUpdate() {
|
||||
if (containerIds.length === 0) return;
|
||||
|
||||
status = 'updating';
|
||||
progress = [];
|
||||
currentIndex = 0;
|
||||
totalCount = containerIds.length;
|
||||
summary = null;
|
||||
errorMessage = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers/batch-update-stream', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ containerIds, vulnerabilityCriteria })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to start update');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const successIds: string[] = [];
|
||||
const failedIds: string[] = [];
|
||||
const blockedIds: string[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.type === 'start') {
|
||||
totalCount = data.total;
|
||||
} else if (data.type === 'progress') {
|
||||
currentIndex = data.current;
|
||||
|
||||
// Update or add progress entry
|
||||
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
|
||||
if (existingIndex >= 0) {
|
||||
progress[existingIndex].step = data.step;
|
||||
progress[existingIndex].success = data.success;
|
||||
progress[existingIndex].error = data.error;
|
||||
progress = [...progress]; // Trigger reactivity
|
||||
} else {
|
||||
progress = [...progress, {
|
||||
containerId: data.containerId,
|
||||
containerName: data.containerName,
|
||||
step: data.step,
|
||||
success: data.success,
|
||||
error: data.error,
|
||||
pullLogs: [],
|
||||
scanLogs: [],
|
||||
showLogs: true // Auto-expand for the first/current container
|
||||
}];
|
||||
}
|
||||
|
||||
// Track success/failed for onComplete callback
|
||||
if (data.success === true) {
|
||||
successIds.push(data.containerId);
|
||||
} else if (data.success === false && data.step === 'failed') {
|
||||
failedIds.push(data.containerId);
|
||||
}
|
||||
} else if (data.type === 'pull_log') {
|
||||
// Add pull log to the container's log list
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
// For layer progress, update existing entry or add new
|
||||
if (data.pullId) {
|
||||
const existingLog = containerProgress.pullLogs.find(l => l.id === data.pullId);
|
||||
if (existingLog) {
|
||||
existingLog.status = data.pullStatus;
|
||||
existingLog.progress = data.pullProgress;
|
||||
} else {
|
||||
containerProgress.pullLogs.push({
|
||||
status: data.pullStatus,
|
||||
id: data.pullId,
|
||||
progress: data.pullProgress
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// General status message (no layer ID)
|
||||
containerProgress.pullLogs.push({
|
||||
status: data.pullStatus
|
||||
});
|
||||
}
|
||||
progress = [...progress]; // Trigger reactivity
|
||||
}
|
||||
} else if (data.type === 'scan_start') {
|
||||
// Update step to scanning
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.step = 'scanning';
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'scan_log') {
|
||||
// Add scan log to the container's log list
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.scanLogs.push({
|
||||
scanner: data.scanner,
|
||||
message: data.message
|
||||
});
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'scan_complete') {
|
||||
// Store scan result and individual scanner results
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.scanResult = data.scanResult;
|
||||
containerProgress.scannerResults = data.scannerResults;
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'blocked') {
|
||||
// Mark container as blocked
|
||||
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
|
||||
if (existingIndex >= 0) {
|
||||
progress[existingIndex].step = 'blocked';
|
||||
progress[existingIndex].success = false;
|
||||
progress[existingIndex].scanResult = data.scanResult;
|
||||
progress[existingIndex].scannerResults = data.scannerResults;
|
||||
progress[existingIndex].blockReason = data.blockReason;
|
||||
progress = [...progress];
|
||||
}
|
||||
blockedIds.push(data.containerId);
|
||||
currentIndex = data.current;
|
||||
} else if (data.type === 'complete') {
|
||||
status = 'complete';
|
||||
summary = data.summary;
|
||||
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
|
||||
} else if (data.type === 'error') {
|
||||
status = 'error';
|
||||
errorMessage = data.error || 'Unknown error occurred';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update containers:', error);
|
||||
status = 'error';
|
||||
errorMessage = error.message || 'Failed to update';
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
// Reset state
|
||||
status = 'idle';
|
||||
progress = [];
|
||||
currentIndex = 0;
|
||||
summary = null;
|
||||
errorMessage = '';
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen && status === 'updating') {
|
||||
// Don't allow closing while updating
|
||||
return;
|
||||
}
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLogs(containerId: string) {
|
||||
const item = progress.find(p => p.containerId === containerId);
|
||||
if (item) {
|
||||
item.showLogs = !item.showLogs;
|
||||
progress = [...progress];
|
||||
}
|
||||
}
|
||||
|
||||
async function forceUpdateContainer(containerId: string) {
|
||||
const item = progress.find(p => p.containerId === containerId);
|
||||
if (!item || item.step !== 'blocked') return;
|
||||
|
||||
// Mark as force-updating
|
||||
forceUpdating = new Set([...forceUpdating, containerId]);
|
||||
|
||||
// Reset container state
|
||||
item.step = 'pulling';
|
||||
item.blockReason = undefined;
|
||||
item.pullLogs = [];
|
||||
item.scanLogs = [];
|
||||
progress = [...progress];
|
||||
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers/batch-update-stream', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ containerIds: [containerId], vulnerabilityCriteria: 'never' })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to start update');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.type === 'progress') {
|
||||
item.step = data.step;
|
||||
item.success = data.success;
|
||||
item.error = data.error;
|
||||
progress = [...progress];
|
||||
|
||||
// Update summary if container succeeded
|
||||
if (data.success === true && summary) {
|
||||
summary.blocked--;
|
||||
summary.success++;
|
||||
summary = { ...summary };
|
||||
}
|
||||
} else if (data.type === 'pull_log') {
|
||||
if (data.pullId) {
|
||||
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
|
||||
if (existingLog) {
|
||||
existingLog.status = data.pullStatus;
|
||||
existingLog.progress = data.pullProgress;
|
||||
} else {
|
||||
item.pullLogs.push({
|
||||
status: data.pullStatus,
|
||||
id: data.pullId,
|
||||
progress: data.pullProgress
|
||||
});
|
||||
}
|
||||
} else {
|
||||
item.pullLogs.push({ status: data.pullStatus });
|
||||
}
|
||||
progress = [...progress];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to force update container:', error);
|
||||
item.step = 'failed';
|
||||
item.error = error.message || 'Force update failed';
|
||||
progress = [...progress];
|
||||
} finally {
|
||||
forceUpdating = new Set([...forceUpdating].filter(id => id !== containerId));
|
||||
}
|
||||
}
|
||||
|
||||
const progressPercentage = $derived(
|
||||
totalCount > 0 ? Math.round((currentIndex / totalCount) * 100) : 0
|
||||
);
|
||||
|
||||
// Start update when modal opens
|
||||
$effect(() => {
|
||||
if (open && status === 'idle' && containerIds.length > 0) {
|
||||
startUpdate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-w-5xl h-[70vh] overflow-hidden flex flex-col" onInteractOutside={(e) => { if (status === 'updating') e.preventDefault(); }}>
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<CircleArrowUp class="w-5 h-5 text-amber-500" />
|
||||
Updating containers
|
||||
{#if vulnerabilityCriteria !== 'never'}
|
||||
<span class="ml-2">
|
||||
<VulnerabilityCriteriaBadge criteria={vulnerabilityCriteria} />
|
||||
</span>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if status === 'updating'}
|
||||
{@const activeContainer = progress.find(p => p.step !== 'done' && p.step !== 'failed' && p.step !== 'blocked')}
|
||||
{#if activeContainer}
|
||||
<span class="text-primary font-medium">
|
||||
{getStepLabel(activeContainer.step)} {activeContainer.containerName}...
|
||||
</span>
|
||||
<span class="text-muted-foreground ml-2">({currentIndex}/{totalCount})</span>
|
||||
{:else}
|
||||
Processing {currentIndex} of {totalCount} containers...
|
||||
{/if}
|
||||
{:else if status === 'complete'}
|
||||
Update complete
|
||||
{:else if status === 'error'}
|
||||
Update failed
|
||||
{:else}
|
||||
Preparing to update {containerIds.length} container{containerIds.length > 1 ? 's' : ''}...
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 min-h-0 space-y-4 py-4 overflow-hidden flex flex-col">
|
||||
<!-- Progress bar -->
|
||||
<div class="space-y-2 shrink-0">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Progress</span>
|
||||
<Badge variant="secondary">{currentIndex}/{totalCount}</Badge>
|
||||
</div>
|
||||
<Progress value={progressPercentage} class="h-2" />
|
||||
</div>
|
||||
|
||||
<!-- Container list with status - scrollable area -->
|
||||
{#if progress.length > 0}
|
||||
<div class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
|
||||
{#each progress as item (item.containerId)}
|
||||
{@const StepIcon = getStepIcon(item.step)}
|
||||
{@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'}
|
||||
{@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0}
|
||||
<div class="text-sm">
|
||||
<!-- Container header -->
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<StepIcon
|
||||
class="w-4 h-4 shrink-0 {getStepColor(item.step)} {isActive ? 'animate-spin' : ''}"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{item.containerName}</div>
|
||||
{#if item.error}
|
||||
<div class="text-xs text-red-600 dark:text-red-400 truncate">{item.error}</div>
|
||||
{:else if item.blockReason}
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400 truncate">{item.blockReason}</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground">{getStepLabel(item.step)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scan result badges - show per scanner when available -->
|
||||
{#if item.scannerResults && item.scannerResults.length > 0}
|
||||
<ScannerSeverityPills results={item.scannerResults} />
|
||||
{:else if item.scanResult}
|
||||
<div class="flex items-center gap-1 text-xs shrink-0">
|
||||
{#if item.scanResult.critical > 0}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Badge variant="destructive" class="px-1.5 py-0 cursor-help">C:{item.scanResult.critical}</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{item.scanResult.critical} Critical vulnerabilities</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if item.scanResult.high > 0}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Badge variant="destructive" class="px-1.5 py-0 bg-orange-500 cursor-help">H:{item.scanResult.high}</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{item.scanResult.high} High severity vulnerabilities</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if item.scanResult.medium > 0}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Badge variant="secondary" class="px-1.5 py-0 bg-amber-500 text-white cursor-help">M:{item.scanResult.medium}</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{item.scanResult.medium} Medium severity vulnerabilities</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if item.scanResult.low > 0}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Badge variant="secondary" class="px-1.5 py-0 cursor-help">L:{item.scanResult.low}</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{item.scanResult.low} Low severity vulnerabilities</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.success === true}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600 shrink-0" />
|
||||
{:else if item.step === 'failed'}
|
||||
<XCircle class="w-4 h-4 text-red-600 shrink-0" />
|
||||
{:else if item.step === 'blocked'}
|
||||
{#if forceUpdating.has(item.containerId)}
|
||||
<Loader2 class="w-4 h-4 text-blue-500 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/50"
|
||||
onclick={() => forceUpdateContainer(item.containerId)}
|
||||
>
|
||||
Update anyway
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if hasLogs}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleLogs(item.containerId)}
|
||||
class="p-1 hover:bg-muted rounded cursor-pointer"
|
||||
title={item.showLogs ? 'Hide logs' : 'Show logs'}
|
||||
>
|
||||
{#if item.showLogs}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pull and Scan logs (collapsible) -->
|
||||
{#if item.showLogs && hasLogs}
|
||||
<div
|
||||
class="bg-muted/50 px-3 py-2 font-mono text-xs max-h-40 overflow-auto border-t overflow-x-hidden"
|
||||
>
|
||||
{#each item.pullLogs as log}
|
||||
<div class="text-muted-foreground break-all">
|
||||
{formatPullLog(log)}
|
||||
</div>
|
||||
{/each}
|
||||
{#if item.scanLogs.length > 0}
|
||||
{#if item.pullLogs.length > 0}
|
||||
<div class="border-t border-dashed my-1 border-muted-foreground/30"></div>
|
||||
{/if}
|
||||
{#each item.scanLogs as log}
|
||||
<div class="text-purple-600 dark:text-purple-400 break-all">
|
||||
{formatScanLog(log)}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary - shrink-0 to stay visible -->
|
||||
{#if summary}
|
||||
<div class="shrink-0 pt-2 border-t">
|
||||
<UpdateSummaryStats
|
||||
updated={summary.success}
|
||||
blocked={summary.blocked}
|
||||
failed={summary.failed}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error message - shrink-0 to stay visible -->
|
||||
{#if errorMessage}
|
||||
<div class="flex items-start gap-2 text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-950/30 rounded-lg overflow-hidden shrink-0">
|
||||
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span class="break-all">{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0">
|
||||
{#if status === 'updating'}
|
||||
<Button variant="outline" disabled>
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Updating...
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
1230
routes/containers/ContainerInspectModal.svelte
Normal file
1230
routes/containers/ContainerInspectModal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
346
routes/containers/ContainerTerminal.svelte
Normal file
346
routes/containers/ContainerTerminal.svelte
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Terminal as TerminalIcon, X, ExternalLink, Shell, User } from 'lucide-svelte';
|
||||
|
||||
// Dynamic imports for browser-only xterm
|
||||
let Terminal: any;
|
||||
let FitAddon: any;
|
||||
let WebLinksAddon: any;
|
||||
let xtermLoaded = $state(false);
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), containerId, containerName, onClose }: Props = $props();
|
||||
|
||||
let terminalRef: HTMLDivElement;
|
||||
let terminal: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let connected = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Shell options
|
||||
const shellOptions = [
|
||||
{ value: '/bin/bash', label: 'Bash' },
|
||||
{ value: '/bin/sh', label: 'Shell (sh)' },
|
||||
{ value: '/bin/zsh', label: 'Zsh' },
|
||||
{ value: '/bin/ash', label: 'Ash (Alpine)' }
|
||||
];
|
||||
|
||||
const userOptions = [
|
||||
{ value: 'root', label: 'root' },
|
||||
{ value: 'nobody', label: 'nobody' },
|
||||
{ value: '', label: 'Container default' }
|
||||
];
|
||||
|
||||
let selectedShell = $state('/bin/bash');
|
||||
let selectedUser = $state('root');
|
||||
let showConfig = $state(true);
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalRef || terminal) return;
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: '#0c0c0c',
|
||||
foreground: '#cccccc',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#0c0c0c',
|
||||
red: '#c50f1f',
|
||||
green: '#13a10e',
|
||||
yellow: '#c19c00',
|
||||
blue: '#0037da',
|
||||
magenta: '#881798',
|
||||
cyan: '#3a96dd',
|
||||
white: '#cccccc',
|
||||
brightBlack: '#767676',
|
||||
brightRed: '#e74856',
|
||||
brightGreen: '#16c60c',
|
||||
brightYellow: '#f9f1a5',
|
||||
brightBlue: '#3b78ff',
|
||||
brightMagenta: '#b4009e',
|
||||
brightCyan: '#61d6d6',
|
||||
brightWhite: '#f2f2f2'
|
||||
}
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
terminal.open(terminalRef);
|
||||
fitAddon.fit();
|
||||
|
||||
// Handle terminal input
|
||||
terminal.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// Connect to container
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!terminal) return;
|
||||
|
||||
error = null;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(selectedShell)}&user=${encodeURIComponent(selectedUser)}`;
|
||||
|
||||
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
|
||||
terminal.writeln(`\x1b[90mShell: ${selectedShell}, User: ${selectedUser || 'default'}\x1b[0m`);
|
||||
terminal.writeln('');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
terminal?.focus();
|
||||
// Send initial resize
|
||||
if (fitAddon && terminal) {
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
ws?.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'output') {
|
||||
terminal?.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
terminal?.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||
} else if (msg.type === 'exit') {
|
||||
terminal?.writeln('\x1b[90m\r\nSession ended.\x1b[0m');
|
||||
connected = false;
|
||||
// Close the dialog after a brief delay so user sees the message
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
terminal?.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('WebSocket error:', e);
|
||||
error = 'Connection error';
|
||||
terminal?.writeln('\x1b[31mConnection error\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
terminal?.writeln('\x1b[90mDisconnected.\x1b[0m');
|
||||
};
|
||||
}
|
||||
|
||||
function startSession() {
|
||||
if (!xtermLoaded) return;
|
||||
showConfig = false;
|
||||
// Wait for DOM update then init terminal
|
||||
setTimeout(() => {
|
||||
initTerminal();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function openInNewWindow() {
|
||||
const params = new URLSearchParams({
|
||||
shell: selectedShell,
|
||||
user: selectedUser,
|
||||
name: containerName
|
||||
});
|
||||
const url = `/terminal/${containerId}?${params.toString()}`;
|
||||
window.open(url, `terminal_${containerId}`, 'width=900,height=600,resizable=yes,scrollbars=no');
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
disconnect();
|
||||
if (terminal) {
|
||||
terminal.dispose();
|
||||
terminal = null;
|
||||
}
|
||||
fitAddon = null;
|
||||
showConfig = true;
|
||||
connected = false;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
cleanup();
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
function handleResize() {
|
||||
if (fitAddon && terminal) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Dynamically load xterm modules (browser only)
|
||||
const xtermModule = await import('@xterm/xterm');
|
||||
const fitModule = await import('@xterm/addon-fit');
|
||||
const webLinksModule = await import('@xterm/addon-web-links');
|
||||
|
||||
// Handle both ESM and CommonJS exports
|
||||
Terminal = xtermModule.Terminal || xtermModule.default?.Terminal;
|
||||
FitAddon = fitModule.FitAddon || fitModule.default?.FitAddon;
|
||||
WebLinksAddon = webLinksModule.WebLinksAddon || webLinksModule.default?.WebLinksAddon;
|
||||
|
||||
// Load CSS
|
||||
await import('@xterm/xterm/css/xterm.css');
|
||||
xtermLoaded = true;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Reset when dialog closes
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<Dialog.Content class="max-w-6xl w-[90vw] h-[80vh] flex flex-col p-0 gap-0">
|
||||
<Dialog.Header class="px-4 py-3 border-b flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<TerminalIcon class="w-5 h-5" />
|
||||
<Dialog.Title>Terminal - {containerName}</Dialog.Title>
|
||||
{#if connected}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Connected
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if showConfig}
|
||||
<div class="flex-1 flex items-center justify-center p-6">
|
||||
<div class="w-full max-w-md space-y-6">
|
||||
<div class="text-center">
|
||||
<TerminalIcon class="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 class="text-lg font-medium">Open terminal session</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Configure the shell and user for this session
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Shell</Label>
|
||||
<Select.Root type="single" bind:value={selectedShell}>
|
||||
<Select.Trigger class="w-full h-10">
|
||||
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span>{shellOptions.find(o => o.value === selectedShell)?.label || 'Select shell'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each shellOptions as option}
|
||||
<Select.Item value={option.value} label={option.label}>
|
||||
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>User</Label>
|
||||
<Select.Root type="single" bind:value={selectedUser}>
|
||||
<Select.Trigger class="w-full h-10">
|
||||
<User class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span>{userOptions.find(o => o.value === selectedUser)?.label || 'Select user'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each userOptions as option}
|
||||
<Select.Item value={option.value} label={option.label}>
|
||||
<User class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={startSession} class="flex-1" disabled={!xtermLoaded}>
|
||||
<TerminalIcon class="w-4 h-4 mr-2" />
|
||||
{xtermLoaded ? 'Connect' : 'Loading...'}
|
||||
</Button>
|
||||
<Button onclick={openInNewWindow} variant="outline" disabled={!xtermLoaded} title="Open in new window">
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 bg-[#0c0c0c] p-2 overflow-hidden">
|
||||
<div bind:this={terminalRef} class="h-full w-full"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
:global(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:global(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
</style>
|
||||
46
routes/containers/ContainerTile.svelte
Normal file
46
routes/containers/ContainerTile.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Box } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
ipv4Address?: string;
|
||||
ipv6Address?: string;
|
||||
macAddress?: string;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { containerId, containerName, ipv4Address, ipv6Address, macAddress, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full p-3 bg-muted rounded-lg space-y-2 text-left hover:bg-muted/80 hover:ring-1 hover:ring-border transition-all cursor-pointer"
|
||||
onclick={onclick}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Box class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span class="font-medium text-sm truncate" title={containerName}>{containerName}</span>
|
||||
</div>
|
||||
<div class="text-xs space-y-1">
|
||||
{#if ipv4Address}
|
||||
<div>
|
||||
<span class="text-muted-foreground">IPv4: </span>
|
||||
<code class="text-foreground">{ipv4Address}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if ipv6Address}
|
||||
<div>
|
||||
<span class="text-muted-foreground">IPv6: </span>
|
||||
<code class="text-foreground">{ipv6Address}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if macAddress}
|
||||
<div>
|
||||
<span class="text-muted-foreground">MAC: </span>
|
||||
<code class="text-foreground">{macAddress}</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<code class="text-2xs text-muted-foreground">{containerId.slice(0, 12)}</code>
|
||||
</button>
|
||||
1657
routes/containers/CreateContainerModal.svelte
Normal file
1657
routes/containers/CreateContainerModal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
1299
routes/containers/EditContainerModal.svelte
Normal file
1299
routes/containers/EditContainerModal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
39
routes/containers/FileBrowserModal.svelte
Normal file
39
routes/containers/FileBrowserModal.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { FolderOpen } from 'lucide-svelte';
|
||||
import FileBrowserPanel from './FileBrowserPanel.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
envId?: number;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), containerId, containerName, envId, onclose }: Props = $props();
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<FolderOpen class="w-5 h-5" />
|
||||
<span>Browse files - {containerName}</span>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Browse, upload, and download files from the container filesystem.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex-1 overflow-hidden border rounded-lg">
|
||||
<FileBrowserPanel {containerId} {envId} canEdit={$canAccess('containers', 'exec')} />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
1283
routes/containers/FileBrowserPanel.svelte
Normal file
1283
routes/containers/FileBrowserPanel.svelte
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user