1.0.7
This commit is contained in:
1 parent
bd7b832394
commit
7643807717
86 files changed
+3644
-1385
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user