This commit is contained in:
jarek committed 2026-05-30 08:42:21 +02:00
1 parent e7100f8926
commit c8b3acc07e
49 files changed
+1414 -492

No files matched your search

@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const tail = parseInt(url.searchParams.get('tail') || '100');
const tail = url.searchParams.get('tail') || '100';
const since = url.searchParams.get('since') || undefined;
const until = url.searchParams.get('until') || undefined;
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
@@ -20,7 +22,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
try {
const logs = await getContainerLogs(params.id, tail, envIdNum);
const logs = await getContainerLogs(params.id, tail === 'all' ? 'all' : parseInt(tail), envIdNum, since, until);
return json({ logs });
} catch (error: any) {
console.error('Error getting container logs:', error?.message || error, error?.stack);
@@ -90,7 +90,7 @@ function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size:
/**
* Handle logs streaming for Hawser Edge connections
*/
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise<Response> {
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number, since?: string, until?: string): Promise<Response> {
// Check if edge agent is connected
if (!isEdgeConnected(environmentId)) {
return new Response(JSON.stringify({ error: 'Edge agent not connected' }), {
@@ -115,7 +115,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
// Ignore - default to demux mode
}
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&timestamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`;
let controllerClosed = false;
let cancelStream: (() => void) | null = null;
@@ -262,6 +262,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const containerId = params.id;
const tail = url.searchParams.get('tail') || '100';
const since = url.searchParams.get('since') || '';
const until = url.searchParams.get('until') || '';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
@@ -277,7 +279,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Handle Hawser Edge mode separately
if (config.type === 'hawser-edge') {
return handleEdgeLogsStream(containerId, tail, config.environmentId!);
return handleEdgeLogsStream(containerId, tail, config.environmentId!, since, until);
}
// First, check if container has TTY enabled and get container name
@@ -311,7 +313,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
// Build the logs URL with follow=true for streaming
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&timestamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`;
let controllerClosed = false;
let abortController: AbortController | null = new AbortController();
+1 -10
View File
@@ -13,6 +13,7 @@ import type { RequestHandler } from './$types';
import v8 from 'node:v8';
import os from 'node:os';
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
import { formatBytes } from '$lib/utils/format';
// Track startup time and initial RSS for growth rate calculation
const startupTime = Date.now();
@@ -99,16 +100,6 @@ export const GET: RequestHandler = async ({ url }) => {
});
};
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const sign = bytes < 0 ? '-' : '';
const abs = Math.abs(bytes);
if (abs < 1024) return `${sign}${abs} B`;
if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`;
if (abs < 1024 * 1024 * 1024) return `${sign}${(abs / (1024 * 1024)).toFixed(1)} MB`;
return `${sign}${(abs / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
+11 -2
View File
@@ -138,6 +138,9 @@ export const DELETE: RequestHandler = async (event) => {
try {
const id = parseInt(params.id);
if (isNaN(id) || id <= 0) {
return json({ error: 'Invalid environment ID' }, { status: 400 });
}
// Get environment name before deletion for audit log
const env = await getEnvironment(id);
@@ -145,6 +148,11 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Safety: never delete directories if env name is empty/whitespace
if (!env.name?.trim()) {
return json({ error: 'Cannot delete environment with empty name' }, { status: 500 });
}
// Close Edge connection if this is a Hawser Edge environment
// This rejects any pending requests and closes the WebSocket
closeEdgeConnection(id);
@@ -186,10 +194,11 @@ export const DELETE: RequestHandler = async (event) => {
unregisterSchedule(id, 'image_prune');
// Clean up stack directory for this environment
// Safety: only delete subdirectory named after the env, never the parent
try {
const stacksDir = getStacksDir();
const envStackDir = join(stacksDir, env.name);
if (existsSync(envStackDir)) {
if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) {
rmSync(envStackDir, { recursive: true, force: true });
}
} catch (err) {
@@ -200,7 +209,7 @@ export const DELETE: RequestHandler = async (event) => {
try {
const gitReposDir = getGitReposDir();
const envGitDir = join(gitReposDir, env.name);
if (existsSync(envGitDir)) {
if (envGitDir !== gitReposDir && envGitDir.startsWith(gitReposDir) && existsSync(envGitDir)) {
rmSync(envGitDir, { recursive: true, force: true });
}
} catch (err) {
+11 -2
View File
@@ -1,6 +1,7 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner';
import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db';
import { inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { createJobResponse } from '$lib/server/sse';
@@ -97,8 +98,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
try {
// Note: getLatestScanForImage signature is (imageId, scanner, environmentId)
const result = await getLatestScanForImage(imageName, scanner, envId);
// Resolve tag to SHA256 ID for reliable cache lookup
let imageId = imageName;
try {
const info = await inspectImage(imageName, envId) as any;
if (info.Id) imageId = info.Id;
} catch {
// Fall back to name if inspect fails
}
const result = await getLatestScanForImage(imageId, scanner, envId);
if (!result) {
return json({ found: false });
}
+6 -4
View File
@@ -138,7 +138,7 @@ interface EdgeContainerLogSource {
/**
* Handle merged logs streaming for Hawser Edge connections
*/
async function handleEdgeMergedLogs(containerIds: string[], tail: string, environmentId: number): Promise<Response> {
async function handleEdgeMergedLogs(containerIds: string[], tail: string, environmentId: number, since?: string, until?: string): Promise<Response> {
// Check if edge agent is connected
if (!isEdgeConnected(environmentId)) {
return new Response(JSON.stringify({ error: 'Edge agent not connected' }), {
@@ -197,7 +197,7 @@ async function handleEdgeMergedLogs(containerIds: string[], tail: string, enviro
};
// Start log stream for this container via Edge
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&timestamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`;
const { cancel } = sendEdgeStreamRequest(
environmentId,
@@ -362,6 +362,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
// Parse container IDs from comma-separated list
const containerIds = url.searchParams.get('containers')?.split(',').filter(Boolean) || [];
const tail = url.searchParams.get('tail') || '100';
const since = url.searchParams.get('since') || '';
const until = url.searchParams.get('until') || '';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
@@ -386,7 +388,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
// Handle Hawser Edge mode separately
if (config.type === 'hawser-edge') {
return handleEdgeMergedLogs(containerIds, tail, config.environmentId!);
return handleEdgeMergedLogs(containerIds, tail, config.environmentId!, since, until);
}
let controllerClosed = false;
@@ -449,7 +451,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const hasTty = info.Config?.Tty ?? false;
// Start log stream for this container
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&timestamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`;
let logsResponse: Response;
if (config.type === 'socket') {
+1 -6
View File
@@ -18,12 +18,7 @@ function parseEnvFile(content: string): Record<string, string> {
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1);
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
const value = trimmed.substring(eqIndex + 1);
result[key] = value;
}
}
+106 -111
View File
@@ -13,6 +13,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { formatPorts, formatExposedPorts } from '$lib/utils/port-format';
import { formatBytes } from '$lib/utils/format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Badge } from '$lib/components/ui/badge';
@@ -86,6 +87,7 @@
import { watchJob } from '$lib/utils/sse-fetch';
import { ipToNumber } from '$lib/utils/ip';
import { formatHostPortUrl } from '$lib/utils/url';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
@@ -94,15 +96,6 @@
// Track change detection for stat highlighting (UI-only, stays in component)
let changedFields = $state<Map<string, Set<string>>>(new Map());
// Format bytes to human readable
function formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0B';
const k = 1024;
const sizes = ['B', 'K', 'M', 'G', 'T'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
}
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory' | 'ports';
type SortDirection = 'asc' | 'desc';
@@ -1903,28 +1896,29 @@
{/if}
{:else if column.id === 'ports'}
{@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []}
{@const customUrl = container.labels?.['dockhand.url']?.trim() || null}
{#if ports.length > 0 || exposedPorts.length > 0 || customUrl}
{@const parsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])}
{#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl}
{@const compactPorts = $appSettings.compactPorts}
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
{@const remainingCount = ports.length - 1}
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
{#if customUrl}
{#if parsedUrl}
<a
href={customUrl}
href={parsedUrl.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0"
title="Open {customUrl} in new tab"
title="Open {parsedUrl.url} in new tab"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{customUrl.replace(/^https?:\/\//, '')}</span>
<span class="max-w-[120px] truncate">{parsedUrl.name || parsedUrl.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
{#each displayPorts as port}
{@const portUrl = container.labels?.[`dockhand.port.${port.publicPort}.url`]?.trim() || null}
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
{@const portUrl = portParsed?.url || null}
{@const url = portUrl || (currentEnvDetails ? getPortUrl(port.publicPort) : null)}
{#if url}
<a
@@ -2010,101 +2004,6 @@
{/snippet}
</ConfirmPopover>
{/if}
{#if !container.systemContainer}
{#if container.state === 'running' || container.state === 'restarting'}
{#if $canAccess('containers', 'stop')}
<ConfirmPopover
open={confirmStopId === container.id}
action="Stop"
itemType="container"
itemName={container.name}
title="Stop"
onConfirm={() => stopContainer(container.id)}
onOpenChange={(open) => confirmStopId = open ? container.id : null}
>
{#snippet children({ open })}
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stoppingId === container.id ? 'animate-pulse text-destructive' : ''}" />
{/snippet}
</ConfirmPopover>
{#if container.state === 'running'}
<button
type="button"
onclick={() => pauseContainer(container.id)}
title="Pause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pause class="w-3 h-3 text-muted-foreground hover:text-yellow-500" />
</button>
{/if}
{/if}
{:else if container.state === 'paused'}
{#if $canAccess('containers', 'start')}
<button
type="button"
onclick={() => unpauseContainer(container.id)}
title="Unpause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{:else}
{#if $canAccess('containers', 'start')}
<button
type="button"
onclick={() => startContainer(container.id)}
title="Start"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{/if}
{#if $canAccess('containers', 'restart')}
<ConfirmPopover
open={confirmRestartId === container.id}
action="Restart"
itemType="container"
itemName={container.name}
title="Restart"
variant="secondary"
onConfirm={() => restartContainer(container.id)}
onOpenChange={(open) => confirmRestartId = open ? container.id : null}
>
{#snippet children({ open })}
<RotateCw class="w-3 h-3 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restartingId === container.id ? 'animate-spin text-foreground' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{/if}
<button
type="button"
onclick={() => inspectContainer(container)}
title="View details"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{#if container.state === 'running' && $canAccess('containers', 'exec')}
<button
type="button"
onclick={() => browseFiles(container)}
title="Browse files"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FolderOpen class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('containers', 'create')}
<button
type="button"
onclick={() => editContainer(container.id)}
title="Edit"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('containers', 'logs')}
{#if hasActiveLogs(container.id)}
<button
@@ -2247,6 +2146,102 @@
</Popover.Root>
{/if}
{/if}
{#if container.state === 'running' && $canAccess('containers', 'exec')}
<button
type="button"
onclick={() => browseFiles(container)}
title="Browse files"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FolderOpen class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
<button
type="button"
onclick={() => inspectContainer(container)}
title="View details"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{#if $canAccess('containers', 'create')}
<button
type="button"
onclick={() => editContainer(container.id)}
title="Edit"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if !container.systemContainer}
{#if container.state === 'paused'}
{#if $canAccess('containers', 'start')}
<button
type="button"
onclick={() => unpauseContainer(container.id)}
title="Unpause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{:else if container.state !== 'running' && container.state !== 'restarting'}
{#if $canAccess('containers', 'start')}
<button
type="button"
onclick={() => startContainer(container.id)}
title="Start"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{/if}
{#if $canAccess('containers', 'restart')}
<ConfirmPopover
open={confirmRestartId === container.id}
action="Restart"
itemType="container"
itemName={container.name}
title="Restart"
variant="secondary"
onConfirm={() => restartContainer(container.id)}
onOpenChange={(open) => confirmRestartId = open ? container.id : null}
>
{#snippet children({ open })}
<RotateCw class="w-3 h-3 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restartingId === container.id ? 'animate-spin text-foreground' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if container.state === 'running' || container.state === 'restarting'}
{#if container.state === 'running'}
<button
type="button"
onclick={() => pauseContainer(container.id)}
title="Pause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pause class="w-3 h-3 text-muted-foreground hover:text-yellow-500" />
</button>
{/if}
{#if $canAccess('containers', 'stop')}
<ConfirmPopover
open={confirmStopId === container.id}
action="Stop"
itemType="container"
itemName={container.name}
title="Stop"
onConfirm={() => stopContainer(container.id)}
onOpenChange={(open) => confirmStopId = open ? container.id : null}
>
{#snippet children({ open })}
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stoppingId === container.id ? 'animate-pulse text-destructive' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{/if}
{/if}
{#if !container.systemContainer && $canAccess('containers', 'remove')}
<ConfirmPopover
open={confirmDeleteId === container.id}
@@ -4,9 +4,13 @@
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, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { toast } from 'svelte-sonner';
import * as Tooltip from '$lib/components/ui/tooltip';
import { copyToClipboard } from '$lib/utils/clipboard';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { formatBytes } from '$lib/utils/format';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
@@ -93,6 +97,92 @@
let editInputRef: HTMLInputElement | null = null;
// Network attach/detach state
interface NetworkListItem {
id: string;
name: string;
driver: string;
}
let availableNetworks = $state<NetworkListItem[]>([]);
let selectedNetwork = $state<string | undefined>(undefined);
let networkConnecting = $state(false);
let networkDisconnecting = $state<string | null>(null);
let networksLoading = $state(false);
const connectedNetworkNames = $derived(
containerData?.NetworkSettings?.Networks
? new Set(Object.keys(containerData.NetworkSettings.Networks))
: new Set<string>()
);
const unconnectedNetworks = $derived(
availableNetworks.filter(n => !connectedNetworkNames.has(n.name))
);
async function fetchNetworks() {
networksLoading = true;
try {
const envId = $currentEnvironment?.id ?? null;
const response = await fetch(appendEnvParam('/api/networks', envId));
if (response.ok) {
availableNetworks = await response.json();
}
} catch (err) {
console.error('Failed to fetch networks:', err);
} finally {
networksLoading = false;
}
}
async function connectToNetwork() {
if (!selectedNetwork || !containerId) return;
networkConnecting = true;
try {
const envId = $currentEnvironment?.id ?? null;
const response = await fetch(appendEnvParam(`/api/networks/${selectedNetwork}/connect`, envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerId, containerName: displayName })
});
if (response.ok) {
const net = availableNetworks.find(n => n.id === selectedNetwork);
toast.success(`Connected to ${net?.name || 'network'}`);
selectedNetwork = undefined;
await fetchContainerInspect();
} else {
const data = await response.json();
toast.error(data.details || 'Failed to connect to network');
}
} catch (err) {
toast.error('Failed to connect to network');
} finally {
networkConnecting = false;
}
}
async function disconnectFromNetwork(networkId: string, networkName: string) {
networkDisconnecting = networkName;
try {
const envId = $currentEnvironment?.id ?? null;
const response = await fetch(appendEnvParam(`/api/networks/${networkId}/disconnect`, envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerId, containerName: displayName })
});
if (response.ok) {
toast.success(`Disconnected from ${networkName}`);
await fetchContainerInspect();
} else {
const data = await response.json();
toast.error(data.details || 'Failed to disconnect from network');
}
} catch (err) {
toast.error('Failed to disconnect from network');
} finally {
networkDisconnecting = null;
}
}
// Current environment details for port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
@@ -203,6 +293,13 @@
}
});
// Fetch available networks when network tab is selected
$effect(() => {
if (open && activeTab === 'network') {
fetchNetworks();
}
});
// Reset when modal closes
$effect(() => {
if (!open) {
@@ -223,6 +320,8 @@
displayName = '';
isEditing = false;
editName = '';
availableNetworks = [];
selectedNetwork = undefined;
}
});
@@ -340,14 +439,6 @@
return formatDateTime(dateString);
}
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(i > 1 ? 2 : 0)} ${sizes[i]}`;
}
function formatMemory(bytes: number): string {
if (!bytes) return 'unlimited';
const mb = bytes / (1024 * 1024);
@@ -825,15 +916,34 @@
{/if}
<!-- Networks -->
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
<div class="space-y-2">
<h3 class="text-sm font-semibold">Connected networks</h3>
<div class="space-y-2">
<h3 class="text-sm font-semibold">Connected networks</h3>
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
<div class="space-y-2">
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]}
{@const netData = networkData as any}
<div class="p-3 border border-border rounded-lg space-y-2">
<div class="flex items-center justify-between">
<span class="font-medium text-sm">{networkName}</span>
<Badge variant="secondary" class="text-xs">{networkData.NetworkID?.slice(0, 12)}</Badge>
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{networkName}</span>
<Badge variant="secondary" class="text-xs">{netData.NetworkID?.slice(0, 12)}</Badge>
</div>
{#if containerData.State?.Running}
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={networkDisconnecting === networkName}
onclick={() => disconnectFromNetwork(netData.NetworkID, networkName)}
>
{#if networkDisconnecting === networkName}
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
{:else}
<Unlink class="w-3 h-3 mr-1" />
{/if}
Leave
</Button>
{/if}
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs">
{#if networkData.IPAddress}
@@ -870,26 +980,74 @@
</div>
{/each}
</div>
</div>
{/if}
{:else}
<p class="text-xs text-muted-foreground">No networks connected.</p>
{/if}
<!-- Join network dropdown -->
{#if containerData.State?.Running}
<div class="flex items-center gap-2 pt-1">
<Select.Root type="single" bind:value={selectedNetwork}>
<Select.Trigger class="flex-1 h-8 text-xs">
{#if selectedNetwork}
{@const net = unconnectedNetworks.find(n => n.id === selectedNetwork)}
<span class="flex items-center gap-2">
<Network class="w-3 h-3 text-muted-foreground" />
{net?.name || 'Unknown'}
<Badge variant="outline" class="text-[10px] px-1 py-0">{net?.driver}</Badge>
</span>
{:else}
<span class="text-muted-foreground">
{networksLoading ? 'Loading networks...' : unconnectedNetworks.length > 0 ? 'Join a network...' : 'No networks available'}
</span>
{/if}
</Select.Trigger>
<Select.Content>
{#each unconnectedNetworks as network}
<Select.Item value={network.id}>
<span class="flex items-center gap-2">
<Network class="w-3 h-3 text-muted-foreground" />
{network.name}
<Badge variant="outline" class="text-[10px] px-1 py-0 ml-auto">{network.driver}</Badge>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button
size="sm"
class="h-8"
disabled={!selectedNetwork || networkConnecting}
onclick={connectToNetwork}
>
{#if networkConnecting}
<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />
{:else}
<Link class="w-3.5 h-3.5 mr-1" />
{/if}
Join
</Button>
</div>
{/if}
</div>
<!-- Ports -->
{#if containerData.NetworkSettings?.Ports && Object.keys(containerData.NetworkSettings.Ports).length > 0}
{@const inspectCustomUrl = containerData.Config?.Labels?.['dockhand.url']?.trim() || null}
{@const inspectParsedUrl = parseCustomUrl(containerData.Config?.Labels?.['dockhand.url'])}
<div class="space-y-2">
<h3 class="text-sm font-semibold">Port mappings</h3>
<div class="flex flex-wrap gap-2">
{#if inspectCustomUrl}
{#if inspectParsedUrl}
<div class="flex items-center gap-2 text-xs p-2 bg-primary/10 rounded">
<a
href={inspectCustomUrl}
href={inspectParsedUrl.url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary hover:underline"
title="Open {inspectCustomUrl}"
title="Open {inspectParsedUrl.url}"
>
<Globe class="w-3 h-3" />
<span>{inspectCustomUrl.replace(/^https?:\/\//, '')}</span>
<span>{inspectParsedUrl.name || inspectParsedUrl.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-3 h-3 opacity-60" />
</a>
</div>
@@ -897,8 +1055,8 @@
{#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]}
{#if hostBindings && hostBindings.length > 0}
{#each hostBindings as binding}
{@const portUrlOverride = containerData.Config?.Labels?.[`dockhand.port.${binding.HostPort}.url`]?.trim() || null}
{@const url = portUrlOverride || getPortUrl(parseInt(binding.HostPort))}
{@const portParsedOverride = parseCustomUrl(containerData.Config?.Labels?.[`dockhand.port.${binding.HostPort}.url`])}
{@const url = portParsedOverride?.url || getPortUrl(parseInt(binding.HostPort))}
<div class="flex items-center gap-2 text-xs p-2 bg-muted rounded">
{#if url}
<a
@@ -5,7 +5,8 @@
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search } from 'lucide-svelte';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte';
import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse';
import * as Tooltip from '$lib/components/ui/tooltip';
import { currentEnvironment } from '$lib/stores/environment';
import { Badge } from '$lib/components/ui/badge';
@@ -305,15 +306,19 @@
// Also consider ports already typed in the form
for (let i = 0; i < portMappings.length; i++) {
if (i !== index && portMappings[i].hostPort) {
usedPorts.add(parseInt(portMappings[i].hostPort));
const p = parseHostPort(portMappings[i].hostPort);
const num = parseInt(p.hostPort);
if (!isNaN(num)) usedPorts.add(num);
}
}
const startFrom = parseInt(portMappings[index].hostPort) || 8080;
const currentParsed = parseHostPort(portMappings[index].hostPort);
const startFrom = parseInt(currentParsed.hostPort) || 8080;
let port = startFrom;
while (usedPorts.has(port) && port < 65535) port++;
if (port <= 65535) {
portMappings[index].hostPort = String(port);
// Preserve IP prefix if present
portMappings[index].hostPort = formatHostPort(currentParsed.hostIp, String(port));
}
} catch {
// Silently fail
@@ -897,28 +902,33 @@
<div class="space-y-2">
{#each portMappings as mapping, index}
<div class="flex gap-2 items-center">
<div class="flex-1 relative group/port">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Host</span>
<Input bind:value={mapping.hostPort} type="number" class="h-9" />
<button
type="button"
class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-primary transition-colors opacity-0 group-hover/port:opacity-100"
onclick={() => findFreePort(index)}
disabled={findingFreePort}
title="Find next available Docker port"
>
{#if findingFreePort}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{:else}
<Search class="w-3.5 h-3.5" />
{/if}
</button>
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Container</span>
<Input bind:value={mapping.containerPort} type="number" class="h-9" />
</div>
{@const parsed = parseHostPort(mapping.hostPort)}
{@const hostPortError = validatePort(parsed.hostPort)}
{@const hostIpError = validateIp(parsed.hostIp)}
{@const containerPortError = validatePort(mapping.containerPort)}
<div class="flex flex-col gap-1">
<div class="flex gap-2 items-center">
<div class="flex-1 relative group/port">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Host</span>
<Input bind:value={mapping.hostPort} type="text" placeholder="e.g. 8080 or 127.0.0.1:8080" class="h-9 {(hostPortError || hostIpError) && mapping.hostPort ? 'border-destructive' : ''}" />
<button
type="button"
class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-primary transition-colors opacity-0 group-hover/port:opacity-100"
onclick={() => findFreePort(index)}
disabled={findingFreePort}
title="Find next available Docker port"
>
{#if findingFreePort}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{:else}
<Search class="w-3.5 h-3.5" />
{/if}
</button>
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Container</span>
<Input bind:value={mapping.containerPort} type="text" placeholder="e.g. 8080 or 8000-8005" class="h-9 {containerPortError && mapping.containerPort ? 'border-destructive' : ''}" />
</div>
<ToggleGroup
value={mapping.protocol}
options={protocolOptions}
@@ -935,11 +945,30 @@
<Trash2 class="w-4 h-4" />
</Button>
</div>
{#if (hostPortError && mapping.hostPort) || (hostIpError && mapping.hostPort) || (containerPortError && mapping.containerPort)}
<p class="text-xs text-destructive pl-1">{hostIpError || hostPortError || containerPortError}</p>
{/if}
</div>
{/each}
</div>
<p class="text-2xs text-muted-foreground/60 flex items-center gap-1">
<Search class="w-3 h-3" />
Hover the host port field and click the search icon to find the next available port. Only checks Docker-published ports.
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<CircleHelp class="w-3.5 h-3.5 text-muted-foreground/70 cursor-help shrink-0" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-xs text-xs" side="right">
<p class="font-medium mb-1">Supported host port formats:</p>
<ul class="space-y-0.5 text-muted-foreground">
<li><code class="text-foreground">8080</code> — bind to all interfaces</li>
<li><code class="text-foreground">127.0.0.1:8080</code> — bind to specific IP</li>
<li><code class="text-foreground">8000-8005</code> — port range (container port must also be a range)</li>
<li>Leave host port empty for random allocation</li>
</ul>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
Hover the host port field and click the search icon to find the next available port.
</p>
</div>
@@ -10,6 +10,7 @@
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
@@ -364,12 +365,13 @@
loading = true;
try {
const ports: any = {};
const ports: Record<string, { HostIp?: string; HostPort: string }> = {};
portMappings
.filter((p) => p.containerPort && p.hostPort)
.filter((p) => p.containerPort)
.forEach((p) => {
const key = `${p.containerPort}/${p.protocol}`;
ports[key] = { HostPort: String(p.hostPort) };
const parsed = parseHostPort(p.hostPort);
const bindings = expandPortBindings(parsed.hostPort, p.containerPort, p.protocol, parsed.hostIp);
Object.assign(ports, bindings);
});
const volumeBinds = volumeMappings
+11 -14
View File
@@ -6,6 +6,8 @@
import { focusFirstInput } from '$lib/utils';
import ContainerSettingsTab from './ContainerSettingsTab.svelte';
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import { parseHostPort, expandPortBindings, formatHostPort } from '$lib/utils/port-parse';
import { formatBytes } from '$lib/utils/format';
// Parse shell command respecting quotes
function parseShellCommand(cmd: string): string[] {
@@ -337,14 +339,6 @@
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i];
}
async function loadContainerData() {
loadingData = true;
try {
@@ -372,14 +366,16 @@
networkMode = 'bridge';
}
// Parse port mappings
// Parse port mappings (include HostIp if present)
const ports = data.HostConfig.PortBindings || {};
portMappings = Object.keys(ports).length > 0
? Object.entries(ports).map(([containerPort, bindings]: [string, any]) => {
const [port, protocol] = containerPort.split('/');
const hostIp = bindings[0]?.HostIp || '';
const hostPort = bindings[0]?.HostPort || '';
return {
containerPort: port,
hostPort: bindings[0]?.HostPort || '',
hostPort: formatHostPort(hostIp, hostPort),
protocol: protocol || 'tcp'
};
})
@@ -860,12 +856,13 @@
if (containerConfigChanged) {
statusMessage = 'Updating container...';
const ports: any = {};
const ports: Record<string, { HostIp?: string; HostPort: string }> = {};
portMappings
.filter((p) => p.containerPort && p.hostPort)
.filter((p) => p.containerPort)
.forEach((p) => {
const key = `${p.containerPort}/${p.protocol}`;
ports[key] = { HostPort: String(p.hostPort) };
const parsed = parseHostPort(p.hostPort);
const bindings = expandPortBindings(parsed.hostPort, p.containerPort, p.protocol, parsed.hostIp);
Object.assign(ports, bindings);
});
const volumeBinds = volumeMappings
@@ -1,5 +1,6 @@
<script lang="ts">
import { Cpu, MemoryStick, Loader2 } from 'lucide-svelte';
import { formatBytes } from '$lib/utils/format';
interface Metrics {
cpuPercent?: number;
@@ -29,15 +30,6 @@
// Only show skeleton if loading AND we don't have metrics yet
const showSkeleton = $derived(loading && !hasMetrics);
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
if (i < 0 || i >= sizes.length) return '0 B';
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function getProgressColor(percent: number): string {
if (percent >= 90) return 'bg-red-500';
if (percent >= 70) return 'bg-amber-500';
@@ -1,5 +1,6 @@
<script lang="ts">
import { Cpu } from 'lucide-svelte';
import { formatBytes } from '$lib/utils/format';
import { Chart, Svg, Area } from 'layerchart';
import { scaleTime } from 'd3-scale';
@@ -31,15 +32,6 @@
(Number.isFinite(metrics.cpuPercent) || Number.isFinite(metrics.memoryPercent))
);
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
if (i < 0 || i >= sizes.length) return '0 B';
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function getProgressColor(percent: number): string {
if (percent >= 90) return 'bg-red-500';
if (percent >= 70) return 'bg-amber-500';
@@ -1,5 +1,6 @@
<script lang="ts">
import { HardDrive, Image, Database, Box, Hammer, Loader2 } from 'lucide-svelte';
import { formatBytes } from '$lib/utils/format';
import { Chart, Svg, Pie, Arc } from 'layerchart';
interface Props {
@@ -34,14 +35,6 @@
].filter(d => d.value > 0)
);
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function getPercentage(value: number): number {
if (totalSize === 0) return 0;
return (value / totalSize) * 100;
+20 -11
View File
@@ -13,6 +13,7 @@
import * as Select from '$lib/components/ui/select';
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed, CircleDot, Circle, Filter } from 'lucide-svelte';
import { broom, whale } from '@lucide/lab';
import { formatBytes } from '$lib/utils/format';
import * as Tooltip from '$lib/components/ui/tooltip';
import { copyToClipboard } from '$lib/utils/clipboard';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
@@ -110,7 +111,7 @@
let sortDirection = $state<SortDirection>('desc');
// Filter state
type UsageFilter = 'all' | 'in-use' | 'unused';
type UsageFilter = 'all' | 'in-use' | 'unused' | 'some-unused';
let usageFilter = $state<UsageFilter>('all');
// Expanded rows state
@@ -292,8 +293,17 @@
// Apply usage filter
if (usageFilter !== 'all') {
filtered = filtered.filter(group => {
const isInUse = group.containers > 0;
return usageFilter === 'in-use' ? isInUse : !isInUse;
if (usageFilter === 'in-use') {
return group.containers > 0;
}
if (usageFilter === 'some-unused') {
// Only images that have BOTH used and unused tags
return group.containers > 0 && group.tags.some(t => t.containers === 0);
}
// 'unused' includes fully unused AND images with some unused tags
const fullyUnused = group.containers === 0;
const someUnused = group.tags.length > 1 && group.tags.some(t => t.containers === 0);
return fullyUnused || someUnused;
});
}
@@ -633,14 +643,6 @@
return `${(mb / 1024).toFixed(2)} GB`;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatImageDate(timestamp: number): string {
return formatDate(new Date(timestamp * 1000));
}
@@ -734,6 +736,9 @@
{:else if usageFilter === 'in-use'}
<CircleDot class="w-3.5 h-3.5 mr-1.5 text-emerald-500 shrink-0" />
<span>In use</span>
{:else if usageFilter === 'some-unused'}
<CircleDot class="w-3.5 h-3.5 mr-1.5 text-amber-500 shrink-0" />
<span>Some unused</span>
{:else}
<Circle class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
<span>Unused</span>
@@ -748,6 +753,10 @@
<CircleDot class="w-4 h-4 mr-2 text-emerald-500" />
In use
</Select.Item>
<Select.Item value="some-unused">
<CircleDot class="w-4 h-4 mr-2 text-amber-500" />
Some unused
</Select.Item>
<Select.Item value="unused">
<Circle class="w-4 h-4 mr-2 text-muted-foreground" />
Unused
+1 -1
View File
@@ -183,7 +183,7 @@
<!-- Layer Stack with Expandable Details -->
<div class="space-y-1">
<h3 class="sticky top-0 z-10 bg-background text-sm font-semibold mb-2 pb-2 flex items-center gap-2">
<h3 class="text-sm font-semibold mb-2 pb-2 flex items-center gap-2">
<Layers class="w-4 h-4" />
Layer stack (bottom to top) - click to expand
</h3>
+123 -11
View File
@@ -10,7 +10,9 @@
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight, Clock, Tag } from 'lucide-svelte';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight, Clock, Tag, Hash } from 'lucide-svelte';
import { wrapHtmlLines } from '$lib/utils/log-lines';
import LogTimeRangeFilter from './LogTimeRangeFilter.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import PageHeader from '$lib/components/PageHeader.svelte';
import TerminalPanel from '../terminal/TerminalPanel.svelte';
@@ -36,6 +38,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
let wordWrap = $state(true);
let showTimestamps = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-timestamps') !== 'false' : true);
let showContainerName = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-container-name') !== 'false' : true);
let showLineNumbers = $state(typeof localStorage !== 'undefined' && localStorage.getItem('dockhand-log-line-numbers') === 'true');
let darkMode = $state(true);
let layoutMode = $state<'single' | 'multi' | 'grouped'>('multi');
let streamingEnabled = $state(true);
@@ -72,6 +75,47 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// RAF-based auto-scroll
let scrollRafPending = false;
// Tail count and since filter
const tailOptions = [
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1K' },
{ value: '5000', label: '5K' },
{ value: '10000', label: '10K' },
{ value: 'all', label: 'All' }
];
let tailCount = $state('500');
let sinceDate = $state('');
let sinceTime = $state('');
let untilDate = $state('');
let untilTime = $state('');
function getTimestamp(date: string, time: string, defaultTime: string): string {
if (!date) return '';
const dateStr = time ? `${date}T${time}` : `${date}T${defaultTime}`;
const ts = Math.floor(new Date(dateStr).getTime() / 1000);
return isNaN(ts) ? '' : String(ts);
}
function getSinceParam(): string {
return getTimestamp(sinceDate, sinceTime, '00:00:00');
}
function getUntilParam(): string {
return getTimestamp(untilDate, untilTime, '23:59:59');
}
function reloadAllLogs() {
logs = '';
pendingText = '';
mergedLogs = []; mergedHtml = '';
if (layoutMode === 'grouped') {
if (streamingEnabled) startGroupedStreaming();
} else if (selectedContainer) {
if (streamingEnabled) startStreaming();
else fetchLogs();
}
}
// Flush pending logs to state (called on timer)
function flushPendingLogs() {
if (pendingLogs.length === 0) {
@@ -172,6 +216,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
fontSize: number;
autoScroll: boolean;
streamingEnabled: boolean;
tailCount: string;
// Selection state (depends on mode)
selectedContainerId: string | null; // for single/multi mode
selectedContainerIds: string[]; // for grouped mode
@@ -201,6 +246,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
fontSize,
autoScroll,
streamingEnabled,
tailCount,
selectedContainerId: selectedContainer?.id ?? null,
selectedContainerIds: Array.from(selectedContainerIds),
stackName
@@ -411,6 +457,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
if (savedUiState.fontSize !== undefined) fontSize = savedUiState.fontSize;
if (savedUiState.autoScroll !== undefined) autoScroll = savedUiState.autoScroll;
if (savedUiState.streamingEnabled !== undefined) streamingEnabled = savedUiState.streamingEnabled;
if (savedUiState.tailCount !== undefined) tailCount = savedUiState.tailCount;
initialStateLoaded = true;
// Fetch data for this environment
@@ -796,12 +843,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
}
async function fetchLogs(tail: number = 500) {
async function fetchLogs(tail?: number | string) {
if (!selectedContainer) return;
const t = tail ?? tailCount;
loading = true;
try {
const response = await fetch(appendEnvParam(`/api/containers/${selectedContainer.id}/logs?tail=${tail}`, envId));
const since = getSinceParam();
const until = getUntilParam();
const response = await fetch(appendEnvParam(`/api/containers/${selectedContainer.id}/logs?tail=${t}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId));
const data = await response.json();
logs = data.logs || 'No logs available';
scrollToBottom(true); // Force scroll on initial load
@@ -819,7 +869,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// For stopped containers, just fetch logs once - no streaming
if (selectedContainer.state !== 'running') {
fetchLogs(500);
fetchLogs();
isConnected = false;
connectionError = null;
return;
@@ -831,7 +881,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
const containerId = selectedContainer.id; // Capture for closure
try {
const url = appendEnvParam(`/api/containers/${containerId}/logs/stream?tail=500`, envId);
const since = getSinceParam();
const until = getUntilParam();
const url = appendEnvParam(`/api/containers/${containerId}/logs/stream?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId);
eventSource = new EventSource(url);
eventSource.addEventListener('connected', () => {
@@ -917,7 +969,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
if (!container) continue;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=100`, envId));
const since = getSinceParam();
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=${tailCount}${since ? `&since=${since}` : ''}`, envId));
const data = await response.json();
const containerName = container.name;
const color = getContainerColor(containerId);
@@ -1003,7 +1056,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
try {
const containerIdsParam = runningIds.join(',');
const url = appendEnvParam(`/api/logs/merged?containers=${containerIdsParam}&tail=100`, envId);
const since = getSinceParam();
const until = getUntilParam();
const url = appendEnvParam(`/api/logs/merged?containers=${containerIdsParam}&tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId);
eventSource = new EventSource(url);
eventSource.addEventListener('connected', (event) => {
@@ -2035,8 +2090,29 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<ArrowDownToLine class="w-3 h-3" />
<span>Auto-scroll</span>
</button>
<!-- Tail lines selector -->
<Select.Root type="single" value={tailCount} onValueChange={(v) => { tailCount = v; saveState(); reloadAllLogs(); }}>
<Select.Trigger class="!h-7 w-[52px] text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}" title="Number of log lines to load">
<span>{tailOptions.find(o => o.value === tailCount)?.label ?? tailCount}</span>
</Select.Trigger>
<Select.Content>
{#each tailOptions as opt}
<Select.Item value={opt.value} label={opt.label} class="pe-2 [&>span:first-child]:hidden">{opt.label} lines</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Time range filter -->
<LogTimeRangeFilter
bind:sinceDate
bind:sinceTime
bind:untilDate
bind:untilTime
{darkMode}
onApply={reloadAllLogs}
onClear={reloadAllLogs}
/>
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => { fontSize = Number(v); saveState(); }}>
<Select.Trigger class="!h-5 !py-0 w-14 text-xs px-1.5 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
<Select.Trigger class="!h-7 w-14 text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
<span>{fontSize}px</span>
</Select.Trigger>
<Select.Content>
@@ -2067,6 +2143,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
>
<Tag class="w-3 h-3 transition-colors {showContainerName ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<button
onclick={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
class="p-1 rounded transition-colors {showLineNumbers ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
>
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<button onclick={toggleTheme} class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}" title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}>
{#if darkMode}
<Sun class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
@@ -2133,7 +2216,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<RefreshCw class="w-8 h-8 animate-spin {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</div>
{/if}
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html formattedMergedHtml()}</pre>
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(formattedMergedHtml())}</pre>
</div>
{/if}
{:else if !selectedContainer}
@@ -2227,8 +2310,29 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<ArrowDownToLine class="w-3 h-3" />
<span>Auto-scroll</span>
</button>
<!-- Tail lines selector -->
<Select.Root type="single" value={tailCount} onValueChange={(v) => { tailCount = v; saveState(); reloadAllLogs(); }}>
<Select.Trigger class="!h-7 w-[52px] text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}" title="Number of log lines to load">
<span>{tailOptions.find(o => o.value === tailCount)?.label ?? tailCount}</span>
</Select.Trigger>
<Select.Content>
{#each tailOptions as opt}
<Select.Item value={opt.value} label={opt.label} class="pe-2 [&>span:first-child]:hidden">{opt.label} lines</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Time range filter -->
<LogTimeRangeFilter
bind:sinceDate
bind:sinceTime
bind:untilDate
bind:untilTime
{darkMode}
onApply={reloadAllLogs}
onClear={reloadAllLogs}
/>
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => { fontSize = Number(v); saveState(); }}>
<Select.Trigger class="!h-5 !py-0 w-14 text-xs px-1.5 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
<Select.Trigger class="!h-7 w-14 text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
<span>{fontSize}px</span>
</Select.Trigger>
<Select.Content>
@@ -2261,6 +2365,14 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
>
<Tag class="w-3 h-3 transition-colors {showContainerName ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Line numbers -->
<button
onclick={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
class="p-1 rounded transition-colors {showLineNumbers ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
>
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Theme toggle -->
<button
onclick={toggleTheme}
@@ -2364,7 +2476,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</div>
{:else}
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html highlightedLogs()}</pre>
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(highlightedLogs())}</pre>
</div>
{/if}
{/if}
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { CalendarClock, X } from 'lucide-svelte';
import * as Popover from '$lib/components/ui/popover';
import { Calendar } from '$lib/components/ui/calendar';
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
interface Props {
sinceDate: string;
sinceTime: string;
untilDate: string;
untilTime: string;
darkMode: boolean;
onApply: () => void;
onClear: () => void;
}
let {
sinceDate = $bindable(),
sinceTime = $bindable(),
untilDate = $bindable(),
untilTime = $bindable(),
darkMode,
onApply,
onClear
}: Props = $props();
let open = $state(false);
let picking = $state<'from' | 'to'>('from');
const hasFilter = $derived(sinceDate || untilDate);
function formatLabel(): string {
const parts: string[] = [];
if (sinceDate) parts.push(sinceDate.slice(5) + ' ' + (sinceTime || '00:00'));
if (untilDate) parts.push(untilDate.slice(5) + ' ' + (untilTime || '23:59'));
if (parts.length === 2) return parts.join(' → ');
if (sinceDate) return parts[0] + ' →';
return '→ ' + parts[0];
}
function parseDate(dateStr: string): CalendarDate | undefined {
if (!dateStr) return undefined;
const [y, m, d] = dateStr.split('-').map(Number);
return new CalendarDate(y, m, d);
}
function toDateString(date: CalendarDate): string {
return `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
}
const sinceCalValue = $derived(parseDate(sinceDate));
const untilCalValue = $derived(parseDate(untilDate));
function onFromSelect(value: any) {
if (!value) return;
sinceDate = toDateString(value);
// Auto-switch to "To" picker
picking = 'to';
// If both dates are set, auto-apply
if (untilDate) {
open = false;
onApply();
}
}
function onToSelect(value: any) {
if (!value) return;
untilDate = toDateString(value);
// If both dates are set, auto-apply and close
if (sinceDate) {
open = false;
onApply();
}
}
function clear() {
sinceDate = '';
sinceTime = '';
untilDate = '';
untilTime = '';
open = false;
onClear();
}
function apply() {
open = false;
onApply();
}
</script>
<Popover.Root bind:open onOpenChange={(v) => { if (v) picking = sinceDate && !untilDate ? 'to' : 'from'; }}>
<Popover.Trigger
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors {hasFilter ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'bg-amber-500/30 ring-1 ring-amber-600/50 text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-300'}"
title={hasFilter ? `Time range: ${formatLabel()}` : 'Filter logs by time range'}
>
<CalendarClock class="w-3 h-3" />
{#if hasFilter}<span class="tabular-nums">{formatLabel()}</span>{/if}
</Popover.Trigger>
<Popover.Content class="w-auto p-0 z-[200]" align="start">
<div class="flex flex-col">
<!-- From/To tabs -->
<div class="flex border-b border-border">
<button
type="button"
onclick={() => picking = 'from'}
class="flex-1 px-3 py-1.5 text-xs font-medium transition-colors {picking === 'from' ? 'text-foreground border-b-2 border-primary' : 'text-muted-foreground hover:text-foreground'}"
>
From{#if sinceDate}<span class="ml-1 text-muted-foreground">({sinceDate.slice(5)})</span>{/if}
</button>
<button
type="button"
onclick={() => picking = 'to'}
class="flex-1 px-3 py-1.5 text-xs font-medium transition-colors {picking === 'to' ? 'text-foreground border-b-2 border-primary' : 'text-muted-foreground hover:text-foreground'}"
>
To{#if untilDate}<span class="ml-1 text-muted-foreground">({untilDate.slice(5)})</span>{/if}
</button>
</div>
<!-- Calendar -->
{#if picking === 'from'}
<Calendar
type="single"
value={sinceCalValue}
onValueChange={onFromSelect}
class="p-2"
/>
<div class="px-3 pb-2">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Time:</span>
<input type="time" bind:value={sinceTime} class="h-7 rounded border border-border px-2 text-xs bg-background text-foreground [color-scheme:dark] focus:outline-none focus:ring-1 focus:ring-ring" />
</div>
</div>
{:else}
<Calendar
type="single"
value={untilCalValue}
onValueChange={onToSelect}
class="p-2"
/>
<div class="px-3 pb-2">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Time:</span>
<input type="time" bind:value={untilTime} class="h-7 rounded border border-border px-2 text-xs bg-background text-foreground [color-scheme:dark] focus:outline-none focus:ring-1 focus:ring-ring" />
</div>
</div>
{/if}
<!-- Actions -->
<div class="flex items-center gap-2 px-3 pb-2">
<button onclick={apply} class="px-2 py-1 rounded text-xs bg-primary text-primary-foreground hover:bg-primary/90" disabled={!sinceDate && !untilDate}>Apply</button>
<button onclick={clear} class="px-2 py-1 rounded text-xs bg-muted text-muted-foreground hover:bg-muted/80">Clear</button>
</div>
</div>
</Popover.Content>
</Popover.Root>
+12 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter } from 'lucide-svelte';
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter, Hash } from 'lucide-svelte';
import { wrapHtmlLines } from '$lib/utils/log-lines';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
@@ -37,6 +38,7 @@
let logsRef: HTMLDivElement;
let wordWrap = $state(true);
let showLineNumbers = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-line-numbers') === 'true');
let fontSize = $state(12);
// RAF-based auto-scroll
@@ -252,6 +254,14 @@
>
<WrapText class="w-3 h-3 transition-colors {wordWrap ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
</button>
<!-- Line numbers -->
<button
onclick={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
class="p-1 rounded hover:bg-zinc-800 transition-colors {showLineNumbers ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : ''}"
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
>
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
</button>
<!-- Search -->
{#if logSearchActive}
<div class="flex items-center gap-1 bg-zinc-800 rounded px-1.5 py-0.5">
@@ -333,7 +343,7 @@
<!-- Logs content -->
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
{#if logs}
<pre class="text-zinc-50 {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html highlightedLogs()}</pre>
<pre class="text-zinc-50 {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
{:else if loading}
<div class="flex items-center justify-center h-full text-muted-foreground">
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
+86 -6
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter, Clock, Tag } from 'lucide-svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter, Clock, Tag, Hash } from 'lucide-svelte';
import { wrapHtmlLines } from '$lib/utils/log-lines';
import LogTimeRangeFilter from './LogTimeRangeFilter.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
@@ -31,6 +33,7 @@
let fontSize = $state(12);
let showTimestamps = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-timestamps') !== 'false' : true);
let showContainerName = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-container-name') !== 'false' : true);
let showLineNumbers = $state(false);
// SSE Streaming state
let streamingEnabled = $state(true);
@@ -61,6 +64,46 @@
let logSearchInputRef: HTMLInputElement | undefined;
const fontSizeOptions = [10, 12, 14, 16];
const tailOptions = [
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1K' },
{ value: '5000', label: '5K' },
{ value: '10000', label: '10K' },
{ value: 'all', label: 'All' }
];
// Tail count and time range filter
let tailCount = $state('500');
let sinceDate = $state('');
let sinceTime = $state('');
let untilDate = $state('');
let untilTime = $state('');
function getTimestamp(date: string, time: string, defaultTime: string): string {
if (!date) return '';
const dateStr = time ? `${date}T${time}` : `${date}T${defaultTime}`;
const ts = Math.floor(new Date(dateStr).getTime() / 1000);
return isNaN(ts) ? '' : String(ts);
}
function getSinceParam(): string {
return getTimestamp(sinceDate, sinceTime, '00:00:00');
}
function getUntilParam(): string {
return getTimestamp(untilDate, untilTime, '23:59:59');
}
function reloadLogs() {
logs = '';
pendingText = '';
if (streamingEnabled && containerId && visible) {
loading = true;
startStreaming();
} else {
fetchLogs();
}
}
// Get terminal font family from theme preferences
let terminalFontFamily = $derived(() => {
@@ -101,6 +144,8 @@
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
if (settings.logSearchFilterMode !== undefined) logSearchFilterMode = settings.logSearchFilterMode;
if (settings.tailCount !== undefined) tailCount = settings.tailCount;
if (settings.showLineNumbers !== undefined) showLineNumbers = settings.showLineNumbers;
} catch {
// Ignore parse errors
}
@@ -117,7 +162,9 @@
fontSize,
autoScroll,
streamingEnabled,
logSearchFilterMode
logSearchFilterMode,
tailCount,
showLineNumbers
}));
}
}
@@ -202,7 +249,9 @@
const currentContainerId = containerId; // Capture for closure
try {
const url = appendEnvParam(`/api/containers/${currentContainerId}/logs/stream?tail=500`, envId);
const since = getSinceParam();
const until = getUntilParam();
const url = appendEnvParam(`/api/containers/${currentContainerId}/logs/stream?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId);
eventSource = new EventSource(url);
eventSource.addEventListener('connected', () => {
@@ -412,7 +461,9 @@
loading = true;
connectionError = null;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=500`, envId));
const since = getSinceParam();
const until = getUntilParam();
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId));
const data = await response.json();
if (!response.ok) {
logs = `Failed to fetch logs: ${data.error || response.statusText}`;
@@ -708,6 +759,27 @@
<Play class="w-3 h-3" />
{/if}
</button>
<!-- Tail lines selector -->
<Select.Root type="single" value={tailCount} onValueChange={(v) => { tailCount = v; saveSettings(); reloadLogs(); }}>
<Select.Trigger size="sm" class="!h-auto !py-0.5 w-[52px] text-xs px-1.5 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'} [&_svg]:size-3" title="Number of log lines to load">
<span>{tailOptions.find(o => o.value === tailCount)?.label ?? tailCount}</span>
</Select.Trigger>
<Select.Content>
{#each tailOptions as opt}
<Select.Item value={opt.value} label={opt.label}>{opt.label} lines</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Time range filter -->
<LogTimeRangeFilter
bind:sinceDate
bind:sinceTime
bind:untilDate
bind:untilTime
{darkMode}
onApply={reloadLogs}
onClear={reloadLogs}
/>
<!-- Auto-scroll button -->
<button
onclick={toggleAutoScroll}
@@ -718,7 +790,7 @@
</button>
<!-- Font size -->
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => updateFontSize(Number(v))}>
<Select.Trigger size="sm" class="!h-5 !py-0 w-14 text-xs px-1.5 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'} [&_svg]:size-3">
<Select.Trigger size="sm" class="!h-auto !py-0.5 w-14 text-xs px-1.5 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'} [&_svg]:size-3">
<span>{fontSize}px</span>
</Select.Trigger>
<Select.Content>
@@ -751,6 +823,14 @@
>
<Tag class="w-3 h-3 transition-colors {showContainerName ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Line numbers -->
<button
onclick={() => { showLineNumbers = !showLineNumbers; saveSettings(); }}
class="p-1 rounded transition-colors {showLineNumbers ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
>
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Theme toggle -->
<button
onclick={toggleTheme}
@@ -854,7 +934,7 @@
<!-- Logs content -->
<div bind:this={logsRef} class="flex-1 overflow-auto p-3">
{#if logs}
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html highlightedLogs()}</pre>
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
{:else if loading}
<p class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-500'}">Connecting to log stream...</p>
{:else}
+4 -6
View File
@@ -11,6 +11,7 @@
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
import { whale } from '@lucide/lab';
import { formatBytes } from '$lib/utils/format';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
@@ -380,12 +381,9 @@
}
}
function formatBytes(bytes?: number): string {
function formatRegistryBytes(bytes?: number): string {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return formatBytes(bytes);
}
function formatDate(dateStr?: string): string {
@@ -683,7 +681,7 @@
</div>
</td>
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
{formatBytes(tag.size)}
{formatRegistryBytes(tag.size)}
</td>
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
{formatDate(tag.lastUpdated)}
+1 -5
View File
@@ -8,6 +8,7 @@
import * as Tabs from '$lib/components/ui/tabs';
import { onMount, onDestroy } from 'svelte';
import { licenseStore } from '$lib/stores/license';
import { formatBytes } from '$lib/utils/format';
import { browser } from '$app/environment';
import LicenseModal from './LicenseModal.svelte';
import PrivacyModal from './PrivacyModal.svelte';
@@ -403,11 +404,6 @@
}, 800);
}
function formatBytes(bytes: number): string {
const gb = bytes / (1024 * 1024 * 1024);
return `${gb.toFixed(1)} GB`;
}
async function fetchSystemInfo() {
try {
const [systemRes, hostRes] = await Promise.all([
@@ -7,6 +7,7 @@
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import { CircleFadingArrowUp, CircleArrowUp, RefreshCw, Info, Trash2 } from 'lucide-svelte';
import { formatDateTime } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
interface Props {
// Update check settings
@@ -43,14 +44,6 @@
timezone = $bindable()
}: Props = $props();
// Format bytes to human-readable string
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
</script>
<!-- Scheduled Update Check Section -->
@@ -416,13 +416,14 @@
id="notif-apprise-urls"
bind:value={formAppriseUrls}
placeholder="gotify://hostname/app-token
gotifys://hostname/app-token?priority=5
discord://webhook_id/webhook_token
slack://token_a/token_b/token_c
mmost://hostname/webhook-token
tgram://bot_token/chat_id
tgram://bot_token/chat_id:topic_id
ntfy://my-topic
ntfy://host/topic?auth=base64token
ntfy://host/topic?auth=base64token&priority=3
ntfys://host/topic?auth=base64token
pushover://user_key/api_token
workflows://hostname/workflow/signature
+97 -57
View File
@@ -13,9 +13,11 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Popover from '$lib/components/ui/popover';
import { formatBytes } from '$lib/utils/format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe } from 'lucide-svelte';
import { formatPorts } from '$lib/utils/port-format';
import { parseCustomUrl } from '$lib/utils/custom-url';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
@@ -124,15 +126,6 @@
return networks[0]?.ipAddress || '-';
}
// Helper: format bytes to human readable
function formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0B';
const k = 1024;
const sizes = ['B', 'K', 'M', 'G', 'T'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
}
// Fetch container stats
let statsAbortController: AbortController | null = null;
@@ -438,6 +431,7 @@
let confirmStopContainerId = $state<string | null>(null);
let confirmRestartContainerId = $state<string | null>(null);
let confirmPauseContainerId = $state<string | null>(null);
let confirmRemoveContainerId = $state<string | null>(null);
// Operation error state (for stack and container operations)
let operationError = $state<{ id: string; title: string; message: string } | null>(null);
@@ -1165,6 +1159,32 @@
}
}
async function removeContainer(containerId: string) {
operationError = null;
containerActionLoading = containerId;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}?force=true`, envId), { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
const errorMsg = data.error || 'Failed to remove container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
return;
}
toast.success('Container removed');
await fetchStacks();
} catch (error) {
console.error('Failed to remove container:', error);
const errorMsg = error instanceof Error ? error.message : 'Failed to remove container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
}
}
function inspectContainer(containerId: string, containerName: string) {
inspectContainerId = containerId;
inspectContainerName = containerName;
@@ -1810,7 +1830,18 @@
<div class="p-1">
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
</div>
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
{:else if stack.status !== 'running' && stack.status !== 'partial' && stack.status !== 'restarting'}
{#if $canAccess('stacks', 'start')}
<button
type="button"
onclick={(e) => { e.stopPropagation(); startStack(stack.name); }}
title="Start"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{:else}
{#if $canAccess('stacks', 'restart')}
<Popover.Root open={restartPopoverOpen[stack.name] ?? false} onOpenChange={(v) => restartPopoverOpen[stack.name] = v}>
<Popover.Trigger asChild>
@@ -1861,17 +1892,6 @@
{/snippet}
</ConfirmPopover>
{/if}
{:else}
{#if $canAccess('stacks', 'start')}
<button
type="button"
onclick={(e) => { e.stopPropagation(); startStack(stack.name); }}
title="Start"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{/if}
{/if}
{#if $canAccess('stacks', 'stop') && stack.status !== 'created' && stack.status !== 'not deployed'}
@@ -2023,25 +2043,29 @@
{/if}
<div class="flex flex-wrap gap-1.5 mb-2 text-2xs">
<!-- Custom URL from dockhand.url label -->
{#if container.labels?.['dockhand.url']?.trim()}
<a
href={container.labels['dockhand.url'].trim()}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Open {container.labels['dockhand.url'].trim()} in new tab"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{container.labels['dockhand.url'].trim().replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{#if parseCustomUrl(container.labels?.['dockhand.url'])}
{@const stackParsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])}
{#if stackParsedUrl}
<a
href={stackParsedUrl.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Open {stackParsedUrl.url} in new tab"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{stackParsedUrl.name || stackParsedUrl.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
{/if}
<!-- Clickable ports with range collapsing -->
{#if container.ports.length > 0}
{@const mappedPorts = formatPorts(container.ports)}
{#each mappedPorts as port}
{@const portUrl = container.labels?.[`dockhand.port.${port.publicPort}.url`]?.trim() || null}
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
{@const portUrl = portParsed?.url || null}
{@const url = portUrl || getPortUrl(port.publicPort)}
{#if url}
<a
@@ -2147,6 +2171,29 @@
{#if isLoading}
<Loader2 class="w-3.5 h-3.5 animate-spin text-muted-foreground" />
{:else}
{#if container.state === 'paused'}
{#if $canAccess('containers', 'unpause')}
<button
type="button"
title="Unpause"
onclick={(e) => unpauseContainer(container.id, e)}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
</button>
{/if}
{:else if container.state !== 'running'}
{#if $canAccess('containers', 'start')}
<button
type="button"
title="Start"
onclick={(e) => startContainer(container.id, e)}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
</button>
{/if}
{/if}
{#if container.state === 'running'}
{#if $canAccess('containers', 'restart')}
<ConfirmPopover
@@ -2193,30 +2240,23 @@
{/snippet}
</ConfirmPopover>
{/if}
{:else if container.state === 'paused'}
{#if $canAccess('containers', 'unpause')}
<button
type="button"
title="Unpause"
onclick={(e) => unpauseContainer(container.id, e)}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
</button>
{/if}
{:else}
{#if $canAccess('containers', 'start')}
<button
type="button"
title="Start"
onclick={(e) => startContainer(container.id, e)}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
</button>
{/if}
{/if}
{/if}
{#if $canAccess('containers', 'remove')}
<ConfirmPopover
open={confirmRemoveContainerId === container.id}
action="Remove"
itemType="container"
itemName={container.service}
title="Remove"
onConfirm={() => removeContainer(container.id)}
onOpenChange={(open) => confirmRemoveContainerId = open ? container.id : null}
>
{#snippet children({ open })}
<Trash2 class="w-3.5 h-3.5 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
</div>
</div>
</div>