This commit is contained in:
Jarek Krochmalski committed 2026-01-11 07:16:18 +01:00
1 parent bd7b832394
commit 7643807717
86 files changed
+3644 -1385

No files matched your search

+11 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import * as Popover from '$lib/components/ui/popover';
@@ -1300,6 +1301,12 @@
loadLayoutMode();
loadStatusFilter();
// Check for image filter from URL params (from images page link)
const imageParam = $page.url.searchParams.get('image');
if (imageParam) {
searchQuery = imageParam;
}
// Load persisted pending updates from database
loadPendingUpdates();
@@ -1687,7 +1694,10 @@
<div class="{isFieldHighlighted(container.id, 'memory') ? 'stat-highlight' : ''} text-right">
{#if containerStats.get(container.id)}
{@const stats = containerStats.get(container.id)}
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}</span>
{@const memoryTooltip = stats.memoryCache > 0
? `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)} (Total: ${formatBytes(stats.memoryRaw)} | Cache: ${formatBytes(stats.memoryCache)})`
: `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)}`}
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title={memoryTooltip}>{formatBytes(stats.memoryUsage)}</span>
{:else if container.state === 'running'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
@@ -543,7 +543,7 @@
<!-- 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"
class="bg-muted/50 px-3 py-2 font-mono text-xs border-t overflow-x-hidden"
>
{#each item.pullLogs as log}
<div class="text-muted-foreground break-all">
@@ -4,10 +4,10 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
import ImageLayersView from '../images/ImageLayersView.svelte';
import LogsPanel from '../logs/LogsPanel.svelte';
import FileBrowserPanel from './FileBrowserPanel.svelte';
@@ -42,6 +42,19 @@
let showRawJson = $state(false);
let jsonCopied = $state(false);
// Label copy state
let copiedLabel = $state<string | null>(null);
async function copyLabel(key: string, value: string) {
try {
await navigator.clipboard.writeText(`${key}=${value}`);
copiedLabel = key;
setTimeout(() => copiedLabel = null, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Processes state
interface ProcessesData {
Titles: string[];
@@ -75,6 +88,44 @@
let editInputRef: HTMLInputElement | null = null;
// Current environment details for port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
function extractHostFromUrl(urlString: string): string | null {
if (!urlString) return null;
// Handle tcp:// URLs (Docker remote)
const tcpMatch = urlString.match(/^tcp:\/\/([^:\/]+)/);
if (tcpMatch) return tcpMatch[1];
// Handle http:// or https:// URLs
const httpMatch = urlString.match(/^https?:\/\/([^:\/]+)/);
if (httpMatch) return httpMatch[1];
// Handle host:port format
const hostPortMatch = urlString.match(/^([^:\/]+):\d+/);
if (hostPortMatch) return hostPortMatch[1];
// Just a hostname
return urlString;
}
function getPortUrl(publicPort: number): string | null {
const env = currentEnvDetails;
if (!env) return null;
// Priority 1: Use publicIp if configured
if (env.publicIp) {
return `http://${env.publicIp}:${publicPort}`;
}
// Priority 2: Extract from host for direct/hawser-standard
const connectionType = env.connectionType || 'socket';
if (connectionType === 'direct' && env.host) {
const host = extractHostFromUrl(env.host);
if (host) return `http://${host}:${publicPort}`;
} else if (connectionType === 'hawser-standard' && env.host) {
const host = extractHostFromUrl(env.host);
if (host) return `http://${host}:${publicPort}`;
}
// No public IP available for socket or hawser-edge
return null;
}
function startEditing() {
editName = displayName;
isEditing = true;
@@ -379,7 +430,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-6xl h-[90vh] flex flex-col">
<Dialog.Content class="max-w-6xl w-full h-[calc(100vh-2rem)] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Box class="w-5 h-5" />
@@ -450,7 +501,7 @@
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 flex flex-col min-h-0">
<div class="flex-1 flex flex-col min-h-[400px]">
{#if loading}
<div class="flex items-center justify-center py-8">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
@@ -461,7 +512,7 @@
</div>
{:else if containerData}
<Tabs.Root bind:value={activeTab} class="w-full h-full flex flex-col">
<Tabs.List class="w-full justify-start shrink-0 flex-wrap">
<Tabs.List class="w-full justify-start shrink-0 flex-wrap h-auto min-h-10 bg-muted rounded-lg">
<Tabs.Trigger value="overview" onclick={() => showLogs = false}>Overview</Tabs.Trigger>
<Tabs.Trigger value="logs" onclick={() => showLogs = true}>Logs</Tabs.Trigger>
<Tabs.Trigger value="layers" onclick={() => showLogs = false}>Layers</Tabs.Trigger>
@@ -470,6 +521,7 @@
<Tabs.Trigger value="mounts" onclick={() => showLogs = false}>Mounts</Tabs.Trigger>
<Tabs.Trigger value="files" onclick={() => showLogs = false}>Files</Tabs.Trigger>
<Tabs.Trigger value="env" onclick={() => showLogs = false}>Environment</Tabs.Trigger>
<Tabs.Trigger value="labels" onclick={() => showLogs = false}>Labels</Tabs.Trigger>
<Tabs.Trigger value="security" onclick={() => showLogs = false}>Security</Tabs.Trigger>
<Tabs.Trigger value="resources" onclick={() => showLogs = false}>Resources</Tabs.Trigger>
<Tabs.Trigger value="health" onclick={() => showLogs = false}>Health</Tabs.Trigger>
@@ -642,23 +694,6 @@
</div>
{/if}
<!-- Labels (collapsible) -->
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<details class="group">
<summary class="text-sm font-semibold cursor-pointer hover:text-primary">
Labels ({Object.keys(containerData.Config.Labels).length})
</summary>
<div class="space-y-1 mt-2 max-h-32 overflow-y-auto">
{#each Object.entries(containerData.Config.Labels) as [key, value]}
<div class="text-xs p-2 bg-muted rounded">
<code class="text-muted-foreground">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
{/each}
</div>
</details>
{/if}
</Tabs.Content>
<!-- Processes Tab -->
@@ -845,8 +880,22 @@
{#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]}
{#if hostBindings && hostBindings.length > 0}
{#each hostBindings as binding}
{@const url = getPortUrl(parseInt(binding.HostPort))}
<div class="flex items-center gap-2 text-xs p-2 bg-muted rounded">
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
{#if url}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary hover:underline"
title="Open {url}"
>
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
<ExternalLink class="w-3 h-3" />
</a>
{:else}
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
{/if}
<span class="text-muted-foreground">→</span>
<code>{containerPort}</code>
</div>
@@ -944,6 +993,37 @@
{/if}
</Tabs.Content>
<!-- Labels Tab -->
<Tabs.Content value="labels" class="space-y-4 overflow-auto">
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<div class="space-y-1">
{#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
<div class="text-xs p-2 bg-muted rounded flex items-start gap-2 group">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No labels</p>
{/if}
</Tabs.Content>
<!-- Security Tab -->
<Tabs.Content value="security" class="space-y-4 overflow-auto">
<!-- Privileged & User -->
@@ -1187,7 +1267,7 @@
<!-- Raw JSON Modal -->
<Dialog.Root bind:open={showRawJson}>
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
<Dialog.Content class="max-w-4xl max-h-[90vh] sm:max-h-[80vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Code class="w-5 h-5" />
@@ -35,12 +35,12 @@
id: number;
name: string;
description?: string;
env_vars?: { key: string; value: 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 }[];
network_mode: string;
restart_policy: string;
networkMode: string;
restartPolicy: string;
}
interface DockerNetwork {
@@ -315,15 +315,15 @@
const configSet = configSets.find((c) => c.id === parseInt(configSetId));
if (!configSet) return;
if (configSet.env_vars && configSet.env_vars.length > 0) {
if (configSet.envVars && configSet.envVars.length > 0) {
if (mode === 'edit') {
// Merge mode for edit
const existingKeys = new Set(envVars.map(e => e.key).filter(k => k));
const newEnvVars = configSet.env_vars.filter(e => !existingKeys.has(e.key));
const newEnvVars = configSet.envVars.filter(e => !existingKeys.has(e.key));
envVars = [...envVars.filter(e => e.key), ...newEnvVars.map(e => ({ ...e }))];
if (envVars.length === 0) envVars = [{ key: '', value: '' }];
} else {
envVars = configSet.env_vars.map((e) => ({ ...e }));
envVars = configSet.envVars.map((e) => ({ ...e }));
}
}
if (configSet.labels && configSet.labels.length > 0) {
@@ -356,11 +356,11 @@
volumeMappings = configSet.volumes.map((v) => ({ ...v }));
}
}
if (configSet.network_mode) {
networkMode = configSet.network_mode;
if (configSet.networkMode) {
networkMode = configSet.networkMode;
}
if (configSet.restart_policy) {
restartPolicy = configSet.restart_policy;
if (configSet.restartPolicy) {
restartPolicy = configSet.restartPolicy;
}
}
@@ -49,12 +49,12 @@
id: number;
name: string;
description?: string;
env_vars?: { key: string; value: 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 }[];
network_mode: string;
restart_policy: string;
networkMode: string;
restartPolicy: string;
}
@@ -564,7 +564,7 @@
</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.Content class="max-w-4xl w-full max-h-[90vh] sm:max-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</Dialog.Title>
<button
@@ -44,12 +44,12 @@
id: number;
name: string;
description?: string;
env_vars?: { key: string; value: 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 }[];
network_mode: string;
restart_policy: string;
networkMode: string;
restartPolicy: string;
}
interface Props {
@@ -897,7 +897,7 @@
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-[56rem] max-h-[90vh] p-0 flex flex-col overflow-hidden" style="min-height: 85vh; height: 85vh;">
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] p-0 flex flex-col overflow-hidden sm:max-h-[85vh]">
<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 flex items-center gap-1">
Edit container
@@ -944,7 +944,7 @@
</Dialog.Header>
{#if loadingData}
<div class="flex-1 flex items-center justify-center text-muted-foreground text-sm" style="min-height: calc(85vh - 120px);">
<div class="flex-1 flex items-center justify-center text-muted-foreground text-sm min-h-[200px]">
<Loader2 class="w-5 h-5 animate-spin mr-2" />
Loading container data...
</div>
@@ -22,7 +22,7 @@
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="w-5 h-5" />
+36 -3
View File
@@ -69,9 +69,25 @@
initialPath?: string;
canEdit?: boolean;
onUsageChange?: (usage: VolumeUsageInfo[], isInUse: boolean) => void;
// File selection mode - when true, clicking a file selects it instead of opening viewer
selectMode?: boolean;
// Regex to filter which files can be selected (used with selectMode)
selectFilter?: RegExp;
// Callback when a file is selected (in selectMode)
onFileSelect?: (path: string, name: string) => void;
}
let { containerId, volumeName, envId, initialPath = '/', canEdit = true, onUsageChange }: Props = $props();
let {
containerId,
volumeName,
envId,
initialPath = '/',
canEdit = true,
onUsageChange,
selectMode = false,
selectFilter,
onFileSelect
}: Props = $props();
// For volume mode, track whether volume is in use (controls editing ability)
let volumeIsInUse = $state(false);
@@ -110,6 +126,15 @@
// Track if this container uses busybox (doesn't support --time-style=iso)
let useSimpleLs = $state(false);
// Selection mode state
let selectedFilePath = $state<string | null>(null);
// Check if a file matches the select filter (for highlighting)
function matchesSelectFilter(name: string): boolean {
if (!selectMode || !selectFilter) return false;
return selectFilter.test(name);
}
// Sort state
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
@@ -696,6 +721,11 @@
if (entry.type === 'directory') {
const newPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
navigateTo(newPath);
} else if (selectMode && entry.type === 'file') {
// In select mode, clicking a file selects it
const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
selectedFilePath = filePath;
onFileSelect?.(filePath, entry.name);
}
// Symlinks are not navigable - target path is displayed for reference
}
@@ -924,8 +954,11 @@
<Table.Body>
{#each displayEntries() as entry (entry.name)}
{@const Icon = getIcon(entry)}
{@const isClickable = entry.type === 'directory'}
<Table.Row class="{isClickable ? 'cursor-pointer' : ''} hover:bg-muted/50 group">
{@const isClickable = entry.type === 'directory' || (selectMode && entry.type === 'file')}
{@const isSelectable = selectMode && entry.type === 'file' && matchesSelectFilter(entry.name)}
{@const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`}
{@const isSelected = selectMode && selectedFilePath === filePath}
<Table.Row class="{isClickable ? 'cursor-pointer' : ''} hover:bg-muted/50 group {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectable ? 'border-l-2 border-l-primary' : ''}">
<Table.Cell class="py-1">
<button
type="button"