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

No files matched your search

+1 -1
View File
@@ -335,7 +335,7 @@
connectionType: env.connectionType || 'socket',
labels: env.labels || [],
scannerEnabled: false,
online: false,
online: undefined, // undefined = connecting, false = offline, true = online
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
+18 -3
View File
@@ -30,7 +30,9 @@
Loader2,
FileX,
Heart,
Search
Search,
Wifi,
Radio
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
@@ -38,7 +40,7 @@
import { canAccess } from '$lib/stores/auth';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
import { formatDateTime } from '$lib/stores/settings';
import { formatDateTime, appSettings } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { DataGrid } from '$lib/components/data-grid';
@@ -643,7 +645,20 @@
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header with inline filters -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<PageHeader icon={Activity} title="Activity" count={visibleEnd > 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
<div class="flex items-center gap-3">
<PageHeader icon={Activity} title="Activity" count={visibleEnd > 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
<Badge variant="outline" class="gap-1.5 {($appSettings.eventCollectionMode || 'stream') === 'stream' ? 'text-green-500 border-green-500/50' : 'text-amber-500 border-amber-500/50'}">
{#if ($appSettings.eventCollectionMode || 'stream') === 'stream'}
<Wifi class="w-3 h-3" />
<span>Stream</span>
{:else if ($appSettings.eventCollectionMode || 'stream') === 'poll'}
<Radio class="w-3 h-3" />
<span>Poll</span><span class="text-[10px] opacity-70">({($appSettings.eventPollInterval || 60000) / 1000}s)</span>
{:else}
<span class="text-muted-foreground">Off</span>
{/if}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- Container name search -->
<div class="relative">
@@ -285,11 +285,19 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
const inspectHeaders: Record<string, string> = {};
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
inspectResponse = await fetch(inspectUrl, {
headers: inspectHeaders,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
const fetchOpts: any = { headers: inspectHeaders };
if (config.type === 'https') {
fetchOpts.tls = {
sessionTimeout: 0, // Disable TLS session caching for mTLS
servername: config.host,
rejectUnauthorized: true
};
if (config.ca) fetchOpts.tls.ca = [config.ca];
if (config.cert) fetchOpts.tls.cert = [config.cert];
if (config.key) fetchOpts.tls.key = config.key;
fetchOpts.keepalive = false;
}
inspectResponse = await fetch(inspectUrl, fetchOpts);
}
if (inspectResponse.ok) {
@@ -341,12 +349,22 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
const logsHeaders: Record<string, string> = {};
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
response = await fetch(logsUrl, {
const fetchOpts: any = {
headers: logsHeaders,
signal: abortController?.signal,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
signal: abortController?.signal
};
if (config.type === 'https') {
fetchOpts.tls = {
sessionTimeout: 0, // Disable TLS session caching for mTLS
servername: config.host,
rejectUnauthorized: true
};
if (config.ca) fetchOpts.tls.ca = [config.ca];
if (config.cert) fetchOpts.tls.cert = [config.cert];
if (config.key) fetchOpts.tls.key = config.key;
fetchOpts.keepalive = false;
}
response = await fetch(logsUrl, fetchOpts);
}
if (!response.ok) {
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getContainerStats } from '$lib/server/docker';
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
@@ -47,6 +47,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
return { read, write };
}
/**
* Calculate memory usage the same way Docker CLI does.
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
* - cgroup v2: subtract inactive_file from stats
* - cgroup v1: subtract total_inactive_file from stats
* See: https://docs.docker.com/engine/containers/runmetrics/
*
* Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
*/
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
const raw = memoryStats?.usage || 0;
const stats = memoryStats?.stats || {};
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
// Only subtract cache if it's less than raw usage (sanity check)
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
return { usage, raw, cache };
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
@@ -67,15 +89,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const stats = await getContainerStats(params.id, envIdNum) as any;
const cpuPercent = calculateCpuPercent(stats);
const memoryUsage = stats.memory_stats?.usage || 0;
const memory = calculateMemoryUsage(stats.memory_stats);
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
const memoryPercent = (memory.usage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
return json({
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage,
memoryUsage: memory.usage,
memoryRaw: memory.raw,
memoryCache: memory.cache,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
@@ -85,6 +109,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
timestamp: Date.now()
});
} catch (error: any) {
// Return 404 for deleted environments so client can clear stale cache
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Failed to get container stats:', error);
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
}
+33 -5
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listContainers, getContainerStats } from '$lib/server/docker';
import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { ContainerStats } from '$lib/types';
@@ -48,6 +48,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
return { read, write };
}
/**
* Calculate memory usage the same way Docker CLI does.
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
* - cgroup v2: subtract inactive_file from stats
* - cgroup v1: subtract total_inactive_file from stats
* See: https://docs.docker.com/engine/containers/runmetrics/
*
* Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
*/
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
const raw = memoryStats?.usage || 0;
const stats = memoryStats?.stats || {};
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
// Only subtract cache if it's less than raw usage (sanity check)
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
return { usage, raw, cache };
}
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
@@ -112,10 +134,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
// Use raw memory usage (total memory attributed to container)
const memoryUsage = stats.memory_stats?.usage || 0;
// Calculate memory usage the same way Docker CLI does (excludes cache)
const memory = calculateMemoryUsage(stats.memory_stats);
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
const memoryPercent = (memory.usage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
@@ -123,7 +145,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
id: container.id,
name: container.name,
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage,
memoryUsage: memory.usage,
memoryRaw: memory.raw,
memoryCache: memory.cache,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
@@ -142,6 +166,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
return json(validStats);
} catch (error: any) {
// Return 404 for deleted environments so client can clear stale cache
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Failed to get container stats:', error);
return json([], { status: 200 }); // Return empty array instead of error
}
+1 -1
View File
@@ -52,7 +52,7 @@ export interface EnvironmentStats {
updateCheckAutoUpdate: boolean;
labels?: string[];
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
online: boolean;
online?: boolean; // undefined = connecting, false = offline, true = online
error?: string;
containers: {
total: number;
@@ -12,7 +12,6 @@ import {
listContainers,
listImages,
listNetworks,
getDockerInfo,
getContainerStats,
getDiskUsage
} from '$lib/server/docker';
@@ -95,6 +94,28 @@ function calculateCpuPercent(stats: any): number {
return 0;
}
/**
* Calculate memory usage the same way Docker CLI does.
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
* - cgroup v2: subtract inactive_file from stats
* - cgroup v1: subtract total_inactive_file from stats
* See: https://docs.docker.com/engine/containers/runmetrics/
*/
function calculateMemoryUsage(memoryStats: any): number {
const usage = memoryStats?.usage || 0;
const stats = memoryStats?.stats || {};
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
// Only subtract cache if it's less than usage (sanity check)
if (cache > 0 && cache < usage) {
return usage - cache;
}
return usage;
}
// Progressive stats loading - returns stats object and emits partial updates via callback
async function getEnvironmentStatsProgressive(
env: any,
@@ -150,23 +171,9 @@ async function getEnvironmentStatsProgressive(
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
}
// Check if Docker is accessible (with 5 second timeout)
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
if (!dockerInfo) {
envStats.error = 'Connection timeout or Docker not accessible';
envStats.loading = undefined; // Clear loading states on error
// Send offline status to client
onPartialUpdate({
id: env.id,
online: false,
error: envStats.error,
loading: undefined
});
return envStats;
}
envStats.online = true;
// Get all database stats in parallel for better performance
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
// Instead, we determine online status from whether listContainers succeeds
const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
@@ -204,10 +211,9 @@ async function getEnvironmentStatsProgressive(
}));
}
// Send initial update with DB data and online status
// Send initial update with DB data (online status determined later by Docker API success)
onPartialUpdate({
id: env.id,
online: true,
metrics: envStats.metrics,
events: envStats.events,
recentEvents: envStats.recentEvents,
@@ -223,9 +229,22 @@ async function getEnvironmentStatsProgressive(
return size && size > 0 ? size : 0;
};
// PHASE 1: Containers (usually fast)
const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, [])
// Track if Docker API is accessible - determined by listContainers success
let dockerApiAccessible = false;
let dockerApiError: string | null = null;
// PHASE 1: Containers (usually fast) - this determines online status
// Use 10s timeout - this is the critical path that determines if env is online
const containersPromise = withTimeout(listContainers(true, env.id), 10000, null)
.then(async (containers) => {
// Timeout returns null
if (containers === null) {
throw new Error('Connection timeout');
}
// If we got here, Docker API is accessible
dockerApiAccessible = true;
envStats.online = true;
envStats.containers.total = containers.length;
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
@@ -236,11 +255,39 @@ async function getEnvironmentStatsProgressive(
onPartialUpdate({
id: env.id,
online: true,
containers: { ...envStats.containers },
loading: { ...envStats.loading! }
});
return containers;
})
.catch((error) => {
// Docker API failed - mark as offline
dockerApiAccessible = false;
const errorStr = String(error);
if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) {
dockerApiError = 'Agent not connected';
} else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
dockerApiError = 'Docker socket not accessible';
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
dockerApiError = 'Connection lost';
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
dockerApiError = 'Connection timeout';
} else {
dockerApiError = 'Connection error';
}
envStats.error = dockerApiError;
envStats.loading!.containers = false;
onPartialUpdate({
id: env.id,
online: false,
error: dockerApiError,
loading: { ...envStats.loading! }
});
return [] as any[];
});
// PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel
@@ -339,7 +386,7 @@ async function getEnvironmentStatsProgressive(
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryUsage = calculateMemoryUsage(stats.memory_stats);
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
+12 -6
View File
@@ -1,9 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -69,6 +70,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json({ error: 'Name is required' }, { status: 400 });
}
// Check if environment with this name already exists
const existing = await getEnvironmentByName(data.name);
if (existing) {
return json({ error: 'An environment with this name already exists' }, { status: 409 });
}
// Host is required for direct and hawser-standard connections
const connectionType = data.connectionType || 'socket';
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
@@ -83,9 +90,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
host: data.host,
port: data.port || 2375,
protocol: data.protocol || 'http',
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
tlsCa: cleanPem(data.tlsCa),
tlsCert: cleanPem(data.tlsCert),
tlsKey: cleanPem(data.tlsKey),
tlsSkipVerify: data.tlsSkipVerify || false,
icon: data.icon || 'globe',
socketPath: data.socketPath || '/var/run/docker.sock',
@@ -124,7 +131,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json(env);
} catch (error) {
console.error('Failed to create environment:', error);
const message = error instanceof Error ? error.message : 'Failed to create environment';
return json({ error: message }, { status: 500 });
return json({ error: 'Failed to create environment' }, { status: 500 });
}
};
+4 -3
View File
@@ -6,6 +6,7 @@ import { deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
@@ -62,9 +63,9 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
host: data.host,
port: data.port,
protocol: data.protocol,
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
tlsCa: cleanPem(data.tlsCa),
tlsCert: cleanPem(data.tlsCert),
tlsKey: cleanPem(data.tlsKey),
tlsSkipVerify: data.tlsSkipVerify,
icon: data.icon,
socketPath: data.socketPath,
+41 -37
View File
@@ -60,50 +60,54 @@ export const POST: RequestHandler = async ({ request }) => {
headers['X-Hawser-Token'] = config.hawserToken;
}
// For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues
if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) {
const fs = await import('node:fs');
let tempCaPath = '';
// For HTTPS with custom CA, client certs, or skip verification, use subprocess to avoid Vite dev server TLS issues
if (protocol === 'https' && (config.tlsCa || config.tlsCert || config.tlsSkipVerify)) {
// Clean PEM content (remove extra whitespace)
const cleanPem = (pem: string) => pem
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');
// Clean the certificate - remove leading/trailing whitespace from each line
let cleanedCa = '';
if (config.tlsCa && !config.tlsSkipVerify) {
cleanedCa = config.tlsCa
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');
tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`;
fs.writeFileSync(tempCaPath, cleanedCa);
}
// Build Bun script that runs outside Vite's process (Vite interferes with TLS)
const tlsConfig = config.tlsSkipVerify
? `tls: { rejectUnauthorized: false }`
: `tls: { ca: await Bun.file('${tempCaPath}').text() }`;
// Pass config as base64-encoded JSON to avoid escaping issues
const tlsConfig = {
url: `https://${host}:${port}/info`,
headers,
tlsSkipVerify: config.tlsSkipVerify || false,
ca: config.tlsCa && !config.tlsSkipVerify ? cleanPem(config.tlsCa) : null,
cert: config.tlsCert ? cleanPem(config.tlsCert) : null,
key: config.tlsKey ? cleanPem(config.tlsKey) : null,
host
};
const configBase64 = Buffer.from(JSON.stringify(tlsConfig)).toString('base64');
// Inline script with config embedded (bun -e doesn't pass argv correctly)
const scriptContent = `
const response = await fetch('https://${host}:${port}/info', {
headers: ${JSON.stringify(headers)},
${tlsConfig}
});
const body = await response.text();
console.log(JSON.stringify({ status: response.status, body }));
const config = JSON.parse(Buffer.from('${configBase64}', 'base64').toString());
try {
const tls = {
sessionTimeout: 0,
servername: config.host,
rejectUnauthorized: !config.tlsSkipVerify
};
if (config.ca) tls.ca = [config.ca];
if (config.cert) tls.cert = [config.cert];
if (config.key) tls.key = config.key;
const response = await fetch(config.url, {
headers: config.headers,
tls,
keepalive: false
});
const body = await response.text();
console.log(JSON.stringify({ status: response.status, body }));
} catch (e) {
console.log(JSON.stringify({ error: e.message }));
}
`;
const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`;
fs.writeFileSync(scriptPath, scriptContent);
const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' });
const proc = Bun.spawn(['bun', '-e', scriptContent], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
// Cleanup temp files
if (tempCaPath) {
try { fs.unlinkSync(tempCaPath); } catch {}
}
try { fs.unlinkSync(scriptPath); } catch {}
if (!output.trim()) {
throw new Error(stderr || 'Empty response from TLS test subprocess');
}
+8 -3
View File
@@ -1,5 +1,5 @@
import type { RequestHandler } from './$types';
import { getDockerEvents } from '$lib/server/docker';
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
@@ -118,8 +118,13 @@ export const GET: RequestHandler = async ({ url }) => {
processEvents();
} catch (error: any) {
console.error('Failed to connect to Docker events:', error);
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
if (error instanceof EnvironmentNotFoundError) {
// Expected error when environment doesn't exist - don't spam logs
sendEvent('error', { message: 'Environment not found' });
} else {
console.error('Failed to connect to Docker events:', error);
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
}
clearInterval(heartbeatInterval);
controller.close();
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
import crypto from 'node:crypto';
import { secureRandomBytes } from '$lib/server/crypto-fallback';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
@@ -94,7 +94,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Generate webhook secret if webhook is enabled
let webhookSecret = data.webhookSecret;
if (data.webhookEnabled && !webhookSecret) {
webhookSecret = crypto.randomBytes(32).toString('hex');
webhookSecret = secureRandomBytes(32).toString('hex');
}
const gitStack = await createGitStack({
+37 -8
View File
@@ -16,7 +16,16 @@ function isDockerHub(url: string): boolean {
lower.includes('registry.hub.docker.com');
}
async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
interface PaginatedTags {
tags: TagInfo[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
hasPrev: boolean;
}
async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize: number = 20): Promise<PaginatedTags> {
// Docker Hub uses a different API
// For official images: https://hub.docker.com/v2/repositories/library/<image>/tags
// For user images: https://hub.docker.com/v2/repositories/<user>/<image>/tags
@@ -27,7 +36,7 @@ async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
repoPath = `library/${imageName}`;
}
const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=100&ordering=last_updated`;
const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=${pageSize}&page=${page}&ordering=last_updated`;
const response = await fetch(url, {
headers: {
@@ -45,12 +54,21 @@ async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
const data = await response.json();
const results = data.results || [];
return results.map((tag: any) => ({
const tags = results.map((tag: any) => ({
name: tag.name,
size: tag.full_size || tag.images?.[0]?.size,
lastUpdated: tag.last_updated || tag.tag_last_pushed,
digest: tag.images?.[0]?.digest
}));
return {
tags,
total: data.count || 0,
page,
pageSize,
hasNext: !!data.next,
hasPrev: !!data.previous
};
}
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
@@ -104,16 +122,18 @@ export const GET: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
const imageName = url.searchParams.get('image');
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '20');
if (!imageName) {
return json({ error: 'Image name is required' }, { status: 400 });
}
let tags: TagInfo[];
let result: PaginatedTags;
if (!registryId) {
// No registry specified, assume Docker Hub
tags = await fetchDockerHubTags(imageName);
result = await fetchDockerHubTags(imageName, page, pageSize);
} else {
const registry = await getRegistry(parseInt(registryId));
if (!registry) {
@@ -121,13 +141,22 @@ export const GET: RequestHandler = async ({ url }) => {
}
if (isDockerHub(registry.url)) {
tags = await fetchDockerHubTags(imageName);
result = await fetchDockerHubTags(imageName, page, pageSize);
} else {
tags = await fetchRegistryTags(registry, imageName);
// V2 registries don't support pagination well, return all tags
const tags = await fetchRegistryTags(registry, imageName);
result = {
tags,
total: tags.length,
page: 1,
pageSize: tags.length,
hasNext: false,
hasPrev: false
};
}
}
return json(tags);
return json(result);
} catch (error: any) {
console.error('Error fetching tags:', error);
+94 -8
View File
@@ -15,14 +15,26 @@ import {
getEventCleanupEnabled,
setEventCleanupEnabled,
getDefaultTimezone,
setDefaultTimezone
setDefaultTimezone,
getEventCollectionMode,
setEventCollectionMode,
getEventPollInterval,
setEventPollInterval,
getMetricsCollectionInterval,
setMetricsCollectionInterval,
getExternalStackPaths,
setExternalStackPaths,
getPrimaryStackLocation,
setPrimaryStackLocation
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
export type EventCollectionMode = 'stream' | 'poll';
export interface GeneralSettings {
confirmDestructive: boolean;
@@ -41,6 +53,10 @@ export interface GeneralSettings {
eventCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
// Background monitoring settings
eventCollectionMode: EventCollectionMode;
eventPollInterval: number;
metricsCollectionInterval: number;
// Theme settings (for when auth is disabled)
lightTheme: string;
darkTheme: string;
@@ -48,6 +64,10 @@ export interface GeneralSettings {
fontSize: string;
gridFontSize: string;
terminalFont: string;
// External stack paths
externalStackPaths: string[];
// Primary stack location
primaryStackLocation: string | null;
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
@@ -61,6 +81,9 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
defaultTrivyArgs: 'image --format json {image}',
logBufferSizeKb: 500,
defaultTimezone: 'UTC',
eventCollectionMode: 'stream',
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
lightTheme: 'default',
darkTheme: 'default',
font: 'system',
@@ -104,12 +127,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupEnabled,
logBufferSizeKb,
defaultTimezone,
eventCollectionMode,
eventPollInterval,
metricsCollectionInterval,
lightTheme,
darkTheme,
font,
fontSize,
gridFontSize,
terminalFont
terminalFont,
externalStackPaths,
primaryStackLocation
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -127,12 +155,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
getEventCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
getEventPollInterval(),
getMetricsCollectionInterval(),
getSetting('theme_light'),
getSetting('theme_dark'),
getSetting('theme_font'),
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font')
getSetting('theme_terminal_font'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
const settings: GeneralSettings = {
@@ -152,12 +185,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupEnabled,
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
eventPollInterval: eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
metricsCollectionInterval: metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
lightTheme: lightTheme ?? DEFAULT_SETTINGS.lightTheme,
darkTheme: darkTheme ?? DEFAULT_SETTINGS.darkTheme,
font: font ?? DEFAULT_SETTINGS.font,
fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
externalStackPaths,
primaryStackLocation
};
return json(settings);
@@ -175,7 +213,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, externalStackPaths, primaryStackLocation } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -228,6 +266,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Refresh system jobs to use the new timezone
await refreshSystemJobs();
}
if (eventCollectionMode !== undefined && (eventCollectionMode === 'stream' || eventCollectionMode === 'poll')) {
await setEventCollectionMode(eventCollectionMode);
// Notify event subprocess to refresh collectors with new mode
sendToEventSubprocess({ type: 'refresh_environments' });
}
if (eventPollInterval !== undefined && typeof eventPollInterval === 'number') {
// Validate: 30s - 300s (30 seconds to 5 minutes)
const validatedInterval = Math.max(30000, Math.min(300000, eventPollInterval));
await setEventPollInterval(validatedInterval);
// Notify event subprocess to refresh collectors with new interval
sendToEventSubprocess({ type: 'refresh_environments' });
}
if (metricsCollectionInterval !== undefined && typeof metricsCollectionInterval === 'number') {
// Validate: 10s - 300s (10 seconds to 5 minutes)
const validatedInterval = Math.max(10000, Math.min(300000, metricsCollectionInterval));
await setMetricsCollectionInterval(validatedInterval);
// Notify metrics subprocess to update its collection interval
sendToMetricsSubprocess({ type: 'update_interval', intervalMs: validatedInterval });
}
if (lightTheme !== undefined && VALID_LIGHT_THEMES.includes(lightTheme)) {
await setSetting('theme_light', lightTheme);
}
@@ -246,6 +303,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) {
await setSetting('theme_terminal_font', terminalFont);
}
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
// Filter to valid non-empty strings
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
await setExternalStackPaths(validPaths);
}
if (primaryStackLocation !== undefined) {
// Accept string or null
if (primaryStackLocation === null || (typeof primaryStackLocation === 'string' && primaryStackLocation.trim())) {
await setPrimaryStackLocation(primaryStackLocation);
} else if (primaryStackLocation === '') {
// Empty string means clear the setting
await setPrimaryStackLocation(null);
}
}
// Fetch all settings in parallel for the response
const [
@@ -265,12 +336,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupEnabledVal,
logBufferSizeKbVal,
defaultTimezoneVal,
eventCollectionModeVal,
eventPollIntervalVal,
metricsCollectionIntervalVal,
lightThemeVal,
darkThemeVal,
fontVal,
fontSizeVal,
gridFontSizeVal,
terminalFontVal
terminalFontVal,
externalStackPathsVal,
primaryStackLocationVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -288,12 +364,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getEventCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
getEventPollInterval(),
getMetricsCollectionInterval(),
getSetting('theme_light'),
getSetting('theme_dark'),
getSetting('theme_font'),
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font')
getSetting('theme_terminal_font'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
const settings: GeneralSettings = {
@@ -313,12 +394,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupEnabled: eventCleanupEnabledVal,
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
eventPollInterval: eventPollIntervalVal ?? DEFAULT_SETTINGS.eventPollInterval,
metricsCollectionInterval: metricsCollectionIntervalVal ?? DEFAULT_SETTINGS.metricsCollectionInterval,
lightTheme: lightThemeVal ?? DEFAULT_SETTINGS.lightTheme,
darkTheme: darkThemeVal ?? DEFAULT_SETTINGS.darkTheme,
font: fontVal ?? DEFAULT_SETTINGS.font,
fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal
};
return json(settings);
+27 -17
View File
@@ -35,11 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const existingNames = new Set(stacks.map((s) => s.name));
for (const source of stackSources) {
// Only add internal/git stacks that aren't already in the list
if (
!existingNames.has(source.stackName) &&
(source.sourceType === 'internal' || source.sourceType === 'git')
) {
// Add stacks from database that aren't already in the Docker list
// This includes internal, git, and external (adopted) stacks that are currently down
if (!existingNames.has(source.stackName)) {
stacks.push({
name: source.stackName,
containers: [],
@@ -78,7 +76,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
try {
const body = await request.json();
const { name, compose, start, envVars, rawEnvContent } = body;
const { name, compose, start, envVars, rawEnvContent, composePath, envPath } = body;
if (!name || typeof name !== 'string') {
return json({ error: 'Stack name is required' }, { status: 400 });
@@ -90,7 +88,10 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// If start is false, only create the compose file without deploying
if (start === false) {
const result = await saveStackComposeFile(name, compose, true);
const result = await saveStackComposeFile(name, compose, true, envIdNum, {
composePath: composePath || undefined,
envPath: envPath || undefined
});
if (!result.success) {
return json({ error: result.error }, { status: 400 });
}
@@ -100,7 +101,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent);
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
@@ -108,14 +109,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum);
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
}
// Record the stack as internally created
// Record the stack as internally created with custom paths if provided
await upsertStackSource({
stackName: name,
environmentId: envIdNum,
sourceType: 'internal'
sourceType: 'internal',
composePath: composePath || undefined,
envPath: envPath || undefined
});
return json({ success: true, started: false });
@@ -124,13 +127,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
// First ensure the stack directory exists by saving compose file
await saveStackComposeFile(name, compose, true);
await saveStackComposeFile(name, compose, true, envIdNum, {
composePath: composePath || undefined,
envPath: envPath || undefined
});
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent);
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
@@ -138,7 +144,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum);
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
}
}
@@ -146,18 +152,22 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
const result = await deployStack({
name,
compose,
envId: envIdNum
envId: envIdNum,
composePath: composePath || undefined,
envPath: envPath || undefined
});
if (!result.success) {
return json({ error: result.error, output: result.output }, { status: 400 });
}
// Record the stack as internally created
// Record the stack as internally created with custom paths if provided
await upsertStackSource({
stackName: name,
environmentId: envIdNum,
sourceType: 'internal'
sourceType: 'internal',
composePath: composePath || undefined,
envPath: envPath || undefined
});
return json({ success: true, started: true, output: result.output });
+1 -4
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { removeStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
import { removeStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -34,9 +34,6 @@ export const DELETE: RequestHandler = async (event) => {
}
return json({ success: true });
} catch (error) {
if (error instanceof ExternalStackError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
@@ -4,22 +4,36 @@ import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/ser
import { authorize } from '$lib/server/authorize';
// GET /api/stacks/[name]/compose - Get compose file content
export const GET: RequestHandler = async ({ params, cookies }) => {
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('stacks', 'view'))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { name } = params;
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
try {
const result = await getStackComposeFile(name);
const result = await getStackComposeFile(name, envIdNum);
if (!result.success) {
return json({ error: result.error }, { status: 404 });
// Return info about what's needed - unified response for all missing compose files
return json({
error: result.error,
needsFileLocation: result.needsFileLocation || false,
composePath: result.composePath,
envPath: result.envPath
}, { status: 404 });
}
return json({ content: result.content, stackDir: result.stackDir });
return json({
content: result.content,
stackDir: result.stackDir,
composePath: result.composePath,
envPath: result.envPath,
suggestedEnvPath: result.suggestedEnvPath
});
} catch (error: any) {
console.error(`Error getting compose file for stack ${name}:`, error);
return json({ error: error.message || 'Failed to get compose file' }, { status: 500 });
@@ -41,16 +55,29 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
try {
const body = await request.json();
const { content, restart = false } = body;
const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } = body;
if (!content || typeof content !== 'string') {
return json({ error: 'Compose file content is required' }, { status: 400 });
}
// Build options object for custom paths, move operation, and file renames
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath)
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
: undefined;
let result;
if (restart) {
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
// Note: deployStack uses requireComposeFile which will use saved paths
// Save paths first if provided
if (pathOptions) {
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
if (!saveResult.success) {
return json({ error: saveResult.error }, { status: 500 });
}
}
result = await deployStack({
name,
compose: content,
@@ -58,8 +85,8 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
forceRecreate: true
});
} else {
// Just save the file without restarting
result = await saveStackComposeFile(name, content);
// Just save the file without restarting (update operation, not create)
result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
}
if (!result.success) {
+1 -4
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { downStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -42,9 +42,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ExternalStackError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
+32 -7
View File
@@ -1,9 +1,9 @@
import { json } from '@sveltejs/kit';
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
import { getStacksDir } from '$lib/server/stacks';
import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
import { findStackDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { join, dirname } from 'node:path';
import type { RequestHandler } from './$types';
/**
@@ -61,12 +61,37 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
// Try to read .env file from stack directory (only contains non-secrets)
const stacksDir = getStacksDir();
const envFilePath = join(stacksDir, stackName, '.env');
// Check if this stack has a custom compose path configured
const source = await getStackSource(stackName, envIdNum);
// Determine the env file path based on path resolution rules:
// - envPath = '' (empty string) → explicitly no env file
// - envPath = '/path/.env' → use custom path
// - envPath = null with composePath → suggest .env next to compose (but don't auto-load)
// - envPath = null without composePath → use default location
let envFilePath: string | null = null;
if (source?.envPath === '') {
// Empty string = explicitly no env file
envFilePath = null;
} else if (source?.envPath) {
// Custom env path specified
envFilePath = source.envPath;
} else if (source?.composePath) {
// Custom compose path but no env path - suggest .env next to compose
// For loading, check if it exists (but don't fail if it doesn't)
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Default location - .env in stack directory
const stackDir = await findStackDir(stackName, envIdNum);
if (stackDir) {
envFilePath = join(stackDir, '.env');
}
}
let fileVars: Record<string, string> = {};
if (existsSync(envFilePath)) {
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
fileVars = parseEnvFile(content);
+59 -10
View File
@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit';
import { getStacksDir } from '$lib/server/stacks';
import { findStackDir, getStackDir } from '$lib/server/stacks';
import { getStackSource } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { join, dirname } from 'node:path';
import type { RequestHandler } from './$types';
/**
@@ -26,11 +27,36 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
const stacksDir = getStacksDir();
const envFilePath = join(stacksDir, stackName, '.env');
// Check if this stack has custom paths configured
const source = await getStackSource(stackName, envIdNum);
// Determine the env file path based on path resolution rules:
// - envPath = '' (empty string) → explicitly no env file
// - envPath = '/path/.env' → use custom path
// - envPath = null with composePath → suggest .env next to compose
// - envPath = null without composePath → use default location
let envFilePath: string | null = null;
if (source?.envPath === '') {
// Empty string = explicitly no env file
return json({ content: '', noEnvFile: true });
} else if (source?.envPath) {
// Custom env path specified
envFilePath = source.envPath;
} else if (source?.composePath) {
// Custom compose path but no env path - suggest .env next to compose
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Default location - .env in stack directory
const stackDir = await findStackDir(stackName, envIdNum);
if (stackDir) {
envFilePath = join(stackDir, '.env');
}
}
let content = '';
if (existsSync(envFilePath)) {
if (envFilePath && existsSync(envFilePath)) {
try {
content = await Bun.file(envFilePath).text();
} catch {
@@ -73,12 +99,35 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
return json({ error: 'Invalid request body: content string required' }, { status: 400 });
}
const stacksDir = getStacksDir();
const stackDir = join(stacksDir, stackName);
const envFilePath = join(stackDir, '.env');
// Check if this stack has custom paths configured
const source = await getStackSource(stackName, envIdNum);
// Only write if stack directory exists
if (!existsSync(stackDir)) {
// Determine the env file path based on path resolution rules:
// - envPath = '' (empty string) → explicitly no env file, don't write
// - envPath = '/path/.env' → use custom path
// - envPath = null with composePath → suggest .env next to compose
// - envPath = null without composePath → use default location
let envFilePath: string | null = null;
if (source?.envPath === '') {
// Empty string = explicitly no env file - don't allow writes
return json({ success: true, noEnvFile: true });
} else if (source?.envPath) {
// Custom env path specified
envFilePath = source.envPath;
} else if (source?.composePath) {
// Custom compose path but no env path - suggest .env next to compose
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Default location - .env in stack directory
const stackDir = await findStackDir(stackName, envIdNum);
if (stackDir) {
envFilePath = join(stackDir, '.env');
}
}
// Only write if we have a valid path
if (!envFilePath) {
return json({ error: 'Stack directory not found' }, { status: 404 });
}
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { restartStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ExternalStackError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { startStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ExternalStackError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
+1 -4
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { stopStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ExternalStackError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
+2 -1
View File
@@ -18,10 +18,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const sources = await getStackSources(envIdNum);
// Convert to a map for easier lookup in the frontend
const sourceMap: Record<string, { sourceType: string; repository?: any }> = {};
const sourceMap: Record<string, { sourceType: string; composePath?: string | null; repository?: any }> = {};
for (const source of sources) {
sourceMap[source.stackName] = {
sourceType: source.sourceType,
composePath: source.composePath,
repository: source.repository
};
}
+1
View File
@@ -186,6 +186,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
nodeVersion: process.version,
platform: os.platform(),
arch: os.arch(),
kernel: os.release(),
memory: bunInfo.memory,
container: containerRuntime,
ownContainer
+11 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import * as Popover from '$lib/components/ui/popover';
@@ -1300,6 +1301,12 @@
loadLayoutMode();
loadStatusFilter();
// Check for image filter from URL params (from images page link)
const imageParam = $page.url.searchParams.get('image');
if (imageParam) {
searchQuery = imageParam;
}
// Load persisted pending updates from database
loadPendingUpdates();
@@ -1687,7 +1694,10 @@
<div class="{isFieldHighlighted(container.id, 'memory') ? 'stat-highlight' : ''} text-right">
{#if containerStats.get(container.id)}
{@const stats = containerStats.get(container.id)}
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}</span>
{@const memoryTooltip = stats.memoryCache > 0
? `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)} (Total: ${formatBytes(stats.memoryRaw)} | Cache: ${formatBytes(stats.memoryCache)})`
: `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)}`}
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title={memoryTooltip}>{formatBytes(stats.memoryUsage)}</span>
{:else if container.state === 'running'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
@@ -543,7 +543,7 @@
<!-- Pull and Scan logs (collapsible) -->
{#if item.showLogs && hasLogs}
<div
class="bg-muted/50 px-3 py-2 font-mono text-xs max-h-40 overflow-auto border-t overflow-x-hidden"
class="bg-muted/50 px-3 py-2 font-mono text-xs border-t overflow-x-hidden"
>
{#each item.pullLogs as log}
<div class="text-muted-foreground break-all">
@@ -4,10 +4,10 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
import ImageLayersView from '../images/ImageLayersView.svelte';
import LogsPanel from '../logs/LogsPanel.svelte';
import FileBrowserPanel from './FileBrowserPanel.svelte';
@@ -42,6 +42,19 @@
let showRawJson = $state(false);
let jsonCopied = $state(false);
// Label copy state
let copiedLabel = $state<string | null>(null);
async function copyLabel(key: string, value: string) {
try {
await navigator.clipboard.writeText(`${key}=${value}`);
copiedLabel = key;
setTimeout(() => copiedLabel = null, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Processes state
interface ProcessesData {
Titles: string[];
@@ -75,6 +88,44 @@
let editInputRef: HTMLInputElement | null = null;
// Current environment details for port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
function extractHostFromUrl(urlString: string): string | null {
if (!urlString) return null;
// Handle tcp:// URLs (Docker remote)
const tcpMatch = urlString.match(/^tcp:\/\/([^:\/]+)/);
if (tcpMatch) return tcpMatch[1];
// Handle http:// or https:// URLs
const httpMatch = urlString.match(/^https?:\/\/([^:\/]+)/);
if (httpMatch) return httpMatch[1];
// Handle host:port format
const hostPortMatch = urlString.match(/^([^:\/]+):\d+/);
if (hostPortMatch) return hostPortMatch[1];
// Just a hostname
return urlString;
}
function getPortUrl(publicPort: number): string | null {
const env = currentEnvDetails;
if (!env) return null;
// Priority 1: Use publicIp if configured
if (env.publicIp) {
return `http://${env.publicIp}:${publicPort}`;
}
// Priority 2: Extract from host for direct/hawser-standard
const connectionType = env.connectionType || 'socket';
if (connectionType === 'direct' && env.host) {
const host = extractHostFromUrl(env.host);
if (host) return `http://${host}:${publicPort}`;
} else if (connectionType === 'hawser-standard' && env.host) {
const host = extractHostFromUrl(env.host);
if (host) return `http://${host}:${publicPort}`;
}
// No public IP available for socket or hawser-edge
return null;
}
function startEditing() {
editName = displayName;
isEditing = true;
@@ -379,7 +430,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-6xl h-[90vh] flex flex-col">
<Dialog.Content class="max-w-6xl w-full h-[calc(100vh-2rem)] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Box class="w-5 h-5" />
@@ -450,7 +501,7 @@
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 flex flex-col min-h-0">
<div class="flex-1 flex flex-col min-h-[400px]">
{#if loading}
<div class="flex items-center justify-center py-8">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
@@ -461,7 +512,7 @@
</div>
{:else if containerData}
<Tabs.Root bind:value={activeTab} class="w-full h-full flex flex-col">
<Tabs.List class="w-full justify-start shrink-0 flex-wrap">
<Tabs.List class="w-full justify-start shrink-0 flex-wrap h-auto min-h-10 bg-muted rounded-lg">
<Tabs.Trigger value="overview" onclick={() => showLogs = false}>Overview</Tabs.Trigger>
<Tabs.Trigger value="logs" onclick={() => showLogs = true}>Logs</Tabs.Trigger>
<Tabs.Trigger value="layers" onclick={() => showLogs = false}>Layers</Tabs.Trigger>
@@ -470,6 +521,7 @@
<Tabs.Trigger value="mounts" onclick={() => showLogs = false}>Mounts</Tabs.Trigger>
<Tabs.Trigger value="files" onclick={() => showLogs = false}>Files</Tabs.Trigger>
<Tabs.Trigger value="env" onclick={() => showLogs = false}>Environment</Tabs.Trigger>
<Tabs.Trigger value="labels" onclick={() => showLogs = false}>Labels</Tabs.Trigger>
<Tabs.Trigger value="security" onclick={() => showLogs = false}>Security</Tabs.Trigger>
<Tabs.Trigger value="resources" onclick={() => showLogs = false}>Resources</Tabs.Trigger>
<Tabs.Trigger value="health" onclick={() => showLogs = false}>Health</Tabs.Trigger>
@@ -642,23 +694,6 @@
</div>
{/if}
<!-- Labels (collapsible) -->
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<details class="group">
<summary class="text-sm font-semibold cursor-pointer hover:text-primary">
Labels ({Object.keys(containerData.Config.Labels).length})
</summary>
<div class="space-y-1 mt-2 max-h-32 overflow-y-auto">
{#each Object.entries(containerData.Config.Labels) as [key, value]}
<div class="text-xs p-2 bg-muted rounded">
<code class="text-muted-foreground">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
{/each}
</div>
</details>
{/if}
</Tabs.Content>
<!-- Processes Tab -->
@@ -845,8 +880,22 @@
{#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]}
{#if hostBindings && hostBindings.length > 0}
{#each hostBindings as binding}
{@const url = getPortUrl(parseInt(binding.HostPort))}
<div class="flex items-center gap-2 text-xs p-2 bg-muted rounded">
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
{#if url}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary hover:underline"
title="Open {url}"
>
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
<ExternalLink class="w-3 h-3" />
</a>
{:else}
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
{/if}
<span class="text-muted-foreground">→</span>
<code>{containerPort}</code>
</div>
@@ -944,6 +993,37 @@
{/if}
</Tabs.Content>
<!-- Labels Tab -->
<Tabs.Content value="labels" class="space-y-4 overflow-auto">
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<div class="space-y-1">
{#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
<div class="text-xs p-2 bg-muted rounded flex items-start gap-2 group">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No labels</p>
{/if}
</Tabs.Content>
<!-- Security Tab -->
<Tabs.Content value="security" class="space-y-4 overflow-auto">
<!-- Privileged & User -->
@@ -1187,7 +1267,7 @@
<!-- Raw JSON Modal -->
<Dialog.Root bind:open={showRawJson}>
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
<Dialog.Content class="max-w-4xl max-h-[90vh] sm:max-h-[80vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Code class="w-5 h-5" />
@@ -35,12 +35,12 @@
id: number;
name: string;
description?: string;
env_vars?: { key: string; value: string }[];
envVars?: { key: string; value: string }[];
labels?: { key: string; value: string }[];
ports?: { hostPort: string; containerPort: string; protocol: string }[];
volumes?: { hostPath: string; containerPath: string; mode: string }[];
network_mode: string;
restart_policy: string;
networkMode: string;
restartPolicy: string;
}
interface DockerNetwork {
@@ -315,15 +315,15 @@
const configSet = configSets.find((c) => c.id === parseInt(configSetId));
if (!configSet) return;
if (configSet.env_vars && configSet.env_vars.length > 0) {
if (configSet.envVars && configSet.envVars.length > 0) {
if (mode === 'edit') {
// Merge mode for edit
const existingKeys = new Set(envVars.map(e => e.key).filter(k => k));
const newEnvVars = configSet.env_vars.filter(e => !existingKeys.has(e.key));
const newEnvVars = configSet.envVars.filter(e => !existingKeys.has(e.key));
envVars = [...envVars.filter(e => e.key), ...newEnvVars.map(e => ({ ...e }))];
if (envVars.length === 0) envVars = [{ key: '', value: '' }];
} else {
envVars = configSet.env_vars.map((e) => ({ ...e }));
envVars = configSet.envVars.map((e) => ({ ...e }));
}
}
if (configSet.labels && configSet.labels.length > 0) {
@@ -356,11 +356,11 @@
volumeMappings = configSet.volumes.map((v) => ({ ...v }));
}
}
if (configSet.network_mode) {
networkMode = configSet.network_mode;
if (configSet.networkMode) {
networkMode = configSet.networkMode;
}
if (configSet.restart_policy) {
restartPolicy = configSet.restart_policy;
if (configSet.restartPolicy) {
restartPolicy = configSet.restartPolicy;
}
}
@@ -49,12 +49,12 @@
id: number;
name: string;
description?: string;
env_vars?: { key: string; value: string }[];
envVars?: { key: string; value: string }[];
labels?: { key: string; value: string }[];
ports?: { hostPort: string; containerPort: string; protocol: string }[];
volumes?: { hostPath: string; containerPath: string; mode: string }[];
network_mode: string;
restart_policy: string;
networkMode: string;
restartPolicy: string;
}
@@ -564,7 +564,7 @@
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] sm:max-h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
<Dialog.Title class="text-base font-semibold">Create new container</Dialog.Title>
<button
@@ -44,12 +44,12 @@
id: number;
name: string;
description?: string;
env_vars?: { key: string; value: string }[];
envVars?: { key: string; value: string }[];
labels?: { key: string; value: string }[];
ports?: { hostPort: string; containerPort: string; protocol: string }[];
volumes?: { hostPath: string; containerPath: string; mode: string }[];
network_mode: string;
restart_policy: string;
networkMode: string;
restartPolicy: string;
}
interface Props {
@@ -897,7 +897,7 @@
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-[56rem] max-h-[90vh] p-0 flex flex-col overflow-hidden" style="min-height: 85vh; height: 85vh;">
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] p-0 flex flex-col overflow-hidden sm:max-h-[85vh]">
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
<Dialog.Title class="text-base font-semibold flex items-center gap-1">
Edit container
@@ -944,7 +944,7 @@
</Dialog.Header>
{#if loadingData}
<div class="flex-1 flex items-center justify-center text-muted-foreground text-sm" style="min-height: calc(85vh - 120px);">
<div class="flex-1 flex items-center justify-center text-muted-foreground text-sm min-h-[200px]">
<Loader2 class="w-5 h-5 animate-spin mr-2" />
Loading container data...
</div>
@@ -22,7 +22,7 @@
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="w-5 h-5" />
+36 -3
View File
@@ -69,9 +69,25 @@
initialPath?: string;
canEdit?: boolean;
onUsageChange?: (usage: VolumeUsageInfo[], isInUse: boolean) => void;
// File selection mode - when true, clicking a file selects it instead of opening viewer
selectMode?: boolean;
// Regex to filter which files can be selected (used with selectMode)
selectFilter?: RegExp;
// Callback when a file is selected (in selectMode)
onFileSelect?: (path: string, name: string) => void;
}
let { containerId, volumeName, envId, initialPath = '/', canEdit = true, onUsageChange }: Props = $props();
let {
containerId,
volumeName,
envId,
initialPath = '/',
canEdit = true,
onUsageChange,
selectMode = false,
selectFilter,
onFileSelect
}: Props = $props();
// For volume mode, track whether volume is in use (controls editing ability)
let volumeIsInUse = $state(false);
@@ -110,6 +126,15 @@
// Track if this container uses busybox (doesn't support --time-style=iso)
let useSimpleLs = $state(false);
// Selection mode state
let selectedFilePath = $state<string | null>(null);
// Check if a file matches the select filter (for highlighting)
function matchesSelectFilter(name: string): boolean {
if (!selectMode || !selectFilter) return false;
return selectFilter.test(name);
}
// Sort state
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
@@ -696,6 +721,11 @@
if (entry.type === 'directory') {
const newPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
navigateTo(newPath);
} else if (selectMode && entry.type === 'file') {
// In select mode, clicking a file selects it
const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
selectedFilePath = filePath;
onFileSelect?.(filePath, entry.name);
}
// Symlinks are not navigable - target path is displayed for reference
}
@@ -924,8 +954,11 @@
<Table.Body>
{#each displayEntries() as entry (entry.name)}
{@const Icon = getIcon(entry)}
{@const isClickable = entry.type === 'directory'}
<Table.Row class="{isClickable ? 'cursor-pointer' : ''} hover:bg-muted/50 group">
{@const isClickable = entry.type === 'directory' || (selectMode && entry.type === 'file')}
{@const isSelectable = selectMode && entry.type === 'file' && matchesSelectFilter(entry.name)}
{@const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`}
{@const isSelected = selectMode && selectedFilePath === filePath}
<Table.Row class="{isClickable ? 'cursor-pointer' : ''} hover:bg-muted/50 group {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectable ? 'border-l-2 border-l-primary' : ''}">
<Table.Cell class="py-1">
<button
type="button"
+67 -56
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp } from 'lucide-svelte';
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp, Loader2 } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
import { goto } from '$app/navigation';
@@ -45,10 +45,11 @@
// Helper flags
const isMini = $derived(is1x1 || is2x1);
const isWide = $derived(width >= 2);
// Show offline when online is explicitly false
// Only delay showing offline if online is undefined (truly unknown state during initial load)
// Show offline only when online is explicitly false (confirmed offline)
// Show connecting when online is undefined (initial load, not yet determined)
const isStillLoading = $derived(stats.loading && Object.values(stats.loading).some(v => v === true));
const showOffline = $derived(stats.online === false || (!stats.online && !isStillLoading));
const showOffline = $derived(stats.online === false);
const showConnecting = $derived(stats.online === undefined);
</script>
<Card.Root
@@ -84,10 +85,12 @@
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
{#if showConnecting}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{:else if showOffline}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
@@ -138,13 +141,13 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-hidden">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="space-y-2">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
@@ -178,10 +181,12 @@
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
{#if showConnecting}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{:else if showOffline}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
@@ -232,10 +237,12 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-hidden">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="flex gap-4">
<div class="space-y-2">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
</div>
{#if stats.recentEvents}
@@ -244,8 +251,6 @@
</div>
{/if}
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
@@ -279,10 +284,12 @@
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
{#if showConnecting}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{:else if showOffline}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
@@ -333,9 +340,11 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.collectMetrics && stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
@@ -343,8 +352,6 @@
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
@@ -378,10 +385,12 @@
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
{#if showConnecting}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{:else if showOffline}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
@@ -432,9 +441,11 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.collectMetrics && stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
@@ -445,8 +456,6 @@
<DashboardRecentEvents events={stats.recentEvents} limit={8} onclick={oneventsclick} />
{/if}
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
@@ -480,10 +489,12 @@
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
{#if showConnecting}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{:else if showOffline}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
@@ -534,9 +545,11 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.collectMetrics && stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
@@ -546,10 +559,8 @@
{#if stats.recentEvents}
<DashboardRecentEvents events={stats.recentEvents} limit={8} onclick={oneventsclick} />
{/if}
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers} />
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers || showConnecting} />
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
@@ -577,11 +588,13 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
@@ -591,11 +604,9 @@
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers} />
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers || showConnecting} />
</div>
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
@@ -623,11 +634,13 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
@@ -640,14 +653,12 @@
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers} />
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers || showConnecting} />
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
<DashboardCpuMemoryCharts metricsHistory={stats.metricsHistory} metrics={stats.metrics} />
{/if}
</div>
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
@@ -675,11 +686,13 @@
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
@@ -689,18 +702,16 @@
{#if stats.recentEvents}
<DashboardRecentEvents events={stats.recentEvents} limit={10} onclick={oneventsclick} />
{/if}
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers} />
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers || showConnecting} />
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
<DashboardCpuMemoryCharts metricsHistory={stats.metricsHistory} metrics={stats.metrics} />
{/if}
<DashboardDiskUsage imagesSize={stats.images.totalSize} volumesSize={stats.volumes.totalSize} containersSize={stats.containersSize} buildCacheSize={stats.buildCacheSize} showPieChart={true} loading={stats.loading?.diskUsage} />
<DashboardDiskUsage imagesSize={stats.images.totalSize} volumesSize={stats.volumes.totalSize} containersSize={stats.containersSize} buildCacheSize={stats.buildCacheSize} showPieChart={true} loading={stats.loading?.diskUsage || showConnecting} />
</div>
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
{/if}
+17 -8
View File
@@ -11,7 +11,8 @@
Unplug,
Icon,
CircleArrowUp,
CircleFadingArrowUp
CircleFadingArrowUp,
Loader2
} from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
@@ -27,7 +28,7 @@
port?: number | null;
icon: string;
socketPath?: string;
online: boolean;
online?: boolean; // undefined = connecting, false = offline, true = online
scannerEnabled: boolean;
collectActivity: boolean;
collectMetrics: boolean;
@@ -40,6 +41,10 @@
compact?: boolean;
}
// Derived states for connecting/offline
const showConnecting = $derived(online === undefined);
const showOffline = $derived(online === false);
let {
name,
host,
@@ -88,10 +93,12 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{name}</span>
{#if online}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
{#if showConnecting}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{:else if showOffline}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block">{hostDisplay}</span>
@@ -124,10 +131,12 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{name}</span>
{#if online}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
{#if showConnecting}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{:else if showOffline}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block">{hostDisplay}</span>
+1
View File
@@ -11,4 +11,5 @@ export { default as DashboardTopContainers } from './dashboard-top-containers.sv
export { default as DashboardDiskUsage } from './dashboard-disk-usage.svelte';
export { default as DashboardCpuMemoryCharts } from './dashboard-cpu-memory-charts.svelte';
export { default as DashboardOfflineState } from './dashboard-offline-state.svelte';
export { default as DashboardConnectingState } from './dashboard-connecting-state.svelte';
export { default as DashboardStatusIcons } from './dashboard-status-icons.svelte';
+98 -9
View File
@@ -7,7 +7,7 @@
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
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 } from 'lucide-svelte';
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 } from 'lucide-svelte';
import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
@@ -46,10 +46,12 @@
imageId: string;
size: number;
created: number;
containers: number;
}>;
totalSize: number;
latestCreated: number;
imageIds: Set<string>;
containers: number;
}
// Check if a registry is Docker Hub
@@ -115,6 +117,8 @@
// Prune state
let confirmPrune = $state(false);
let pruneStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
let confirmPruneUnused = $state(false);
let pruneUnusedStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
// Multi-select state
let selectedImages = $state<Set<string>>(new Set());
@@ -179,7 +183,8 @@
tags: [],
totalSize: 0,
latestCreated: 0,
imageIds: new Set()
imageIds: new Set(),
containers: 0
});
}
const group = groups.get(key)!;
@@ -188,11 +193,13 @@
fullRef: image.id,
imageId: image.id,
size: image.size,
created: image.created
created: image.created,
containers: image.containers
});
group.totalSize = Math.max(group.totalSize, image.size);
group.latestCreated = Math.max(group.latestCreated, image.created);
group.imageIds.add(image.id);
group.containers += image.containers;
} else {
for (const fullTag of image.tags) {
const colonIndex = fullTag.lastIndexOf(':');
@@ -205,7 +212,8 @@
tags: [],
totalSize: 0,
latestCreated: 0,
imageIds: new Set()
imageIds: new Set(),
containers: 0
});
}
@@ -217,11 +225,16 @@
fullRef: fullTag,
imageId: image.id,
size: image.size,
created: image.created
created: image.created,
containers: image.containers
});
}
group.totalSize = Math.max(group.totalSize, image.size);
group.latestCreated = Math.max(group.latestCreated, image.created);
// Only add containers count once per unique image ID
if (!group.imageIds.has(image.id)) {
group.containers += image.containers;
}
group.imageIds.add(image.id);
}
}
@@ -432,7 +445,7 @@
const response = await fetch(appendEnvParam('/api/prune/images', envId), { method: 'POST' });
if (response.ok) {
pruneStatus = 'success';
toast.success('Unused images pruned');
toast.success('Dangling images pruned');
await fetchImages();
} else {
pruneStatus = 'error';
@@ -445,6 +458,26 @@
pendingTimeouts.push(setTimeout(() => { pruneStatus = 'idle'; }, 3000));
}
async function pruneUnusedImages() {
pruneUnusedStatus = 'pruning';
confirmPruneUnused = false;
try {
const response = await fetch(appendEnvParam('/api/prune/images?dangling=false', envId), { method: 'POST' });
if (response.ok) {
pruneUnusedStatus = 'success';
toast.success('Unused images pruned');
await fetchImages();
} else {
pruneUnusedStatus = 'error';
toast.error('Failed to prune unused images');
}
} catch (error) {
pruneUnusedStatus = 'error';
toast.error('Failed to prune unused images');
}
pendingTimeouts.push(setTimeout(() => { pruneUnusedStatus = 'idle'; }, 3000));
}
async function removeImage(id: string, tagName: string) {
deleteError = null;
try {
@@ -618,13 +651,16 @@
open={confirmPrune}
action="Prune"
itemType="dangling images"
title="Prune images"
title="Prune dangling images"
position="left"
onConfirm={pruneImages}
onOpenChange={(open) => confirmPrune = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}">
<span
class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}"
title="Remove untagged intermediate layers (dangling images)"
>
{#if pruneStatus === 'pruning'}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
{:else if pruneStatus === 'success'}
@@ -638,6 +674,33 @@
</span>
{/snippet}
</ConfirmPopover>
<ConfirmPopover
open={confirmPruneUnused}
action="Prune"
itemType="all unused images"
title="Prune unused images"
position="left"
onConfirm={pruneUnusedImages}
onOpenChange={(open) => confirmPruneUnused = open}
>
{#snippet children({ open })}
<span
class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneUnusedStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}"
title="Remove ALL images not used by any container (including tagged images)"
>
{#if pruneUnusedStatus === 'pruning'}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
{:else if pruneUnusedStatus === 'success'}
<Check class="w-3.5 h-3.5 text-green-600" />
{:else if pruneUnusedStatus === 'error'}
<XCircle class="w-3.5 h-3.5 text-destructive" />
{:else}
<Icon iconNode={broom} class="w-3.5 h-3.5 text-amber-600" />
{/if}
Prune unused
</span>
{/snippet}
</ConfirmPopover>
{/if}
<Button size="sm" variant="outline" onclick={fetchImages}>Refresh</Button>
</div>
@@ -785,6 +848,16 @@
{group.tags[0].tag}
</span>
{/if}
{#if group.containers === 0}
<Badge variant="outline" class="text-2xs px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400 shadow-[0_0_4px_rgba(245,158,11,0.4)]">
Unused
</Badge>
{:else if group.tags.length > 1 && group.tags.some(t => t.containers === 0)}
<Badge variant="outline" class="text-2xs px-1.5 py-0 border-amber-500/30 text-amber-600/70 dark:text-amber-400/70 shadow-[0_0_3px_rgba(245,158,11,0.25)]" title="Some tags are unused">
<CircleDashed class="w-2.5 h-2.5 mr-0.5" />
Some unused
</Badge>
{/if}
</div>
{:else if column.id === 'tags'}
<Badge variant="secondary" class="text-xs">
@@ -867,6 +940,22 @@
<span class="text-muted-foreground">{formatSize(tagInfo.size)}</span>
{:else if column.id === 'created'}
<span class="text-muted-foreground">{formatImageDate(tagInfo.created)}</span>
{:else if column.id === 'used'}
{#if tagInfo.containers > 0}
<a
href="/containers?image={encodeURIComponent(tagInfo.fullRef)}"
class="text-muted-foreground hover:text-foreground hover:underline"
title="View containers using this image"
>
{tagInfo.containers} container{tagInfo.containers === 1 ? '' : 's'}
</a>
{:else if tagInfo.containers === 0}
<Badge variant="outline" class="text-2xs px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400 shadow-[0_0_4px_rgba(245,158,11,0.4)]">
Unused
</Badge>
{:else}
<span class="text-muted-foreground/50">—</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex items-center gap-1">
{#if $canAccess('images', 'inspect')}
@@ -930,7 +1019,7 @@
<Tag class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('images', 'remove')}
{#if $canAccess('images', 'remove') && tagInfo.containers === 0}
<div class="relative">
<ConfirmPopover
open={confirmDeleteId === tagInfo.fullRef}
@@ -259,7 +259,7 @@
<Popover.Trigger asChild>
{@render children()}
</Popover.Trigger>
<Popover.Content class="w-[28rem] p-0 overflow-hidden flex flex-col max-h-96 z-[150]" align="end" sideOffset={8}>
<Popover.Content class="w-[28rem] p-0 overflow-hidden flex flex-col max-h-96 z-[200]" align="end" sideOffset={8}>
<!-- Sticky Header -->
<div class="p-3 border-b shrink-0 space-y-2">
<div class="flex items-center gap-2 text-sm font-medium min-w-0">
@@ -277,7 +277,7 @@
</Tabs.Trigger>
</Tabs.List>
<div class="min-h-[400px] mt-4">
<div class="min-h-[200px] sm:min-h-[300px] mt-4">
<!-- Basic Tab -->
<Tabs.Content value="basic" class="space-y-4 h-full overflow-y-auto">
<div class="space-y-2">
@@ -61,7 +61,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl h-[90vh] flex flex-col">
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Network class="w-5 h-5" />
+110 -29
View File
@@ -43,8 +43,13 @@
interface ExpandedImageState {
loading: boolean;
loadingMore: boolean;
error: string;
tags: TagInfo[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
}
let registries = $state<Registry[]>([]);
@@ -226,38 +231,100 @@
const { [imageName]: _, ...rest } = expandedImages;
expandedImages = rest;
} else {
// Expand and fetch tags
expandedImages = {
...expandedImages,
[imageName]: { loading: true, error: '', tags: [] }
};
// Expand and fetch first page
await fetchTagsPage(imageName, 1, true);
}
}
try {
let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}`;
if (selectedRegistryId) {
url += `&registry=${selectedRegistryId}`;
}
async function loadMoreTags(imageName: string) {
const state = expandedImages[imageName];
if (!state || state.loading || state.loadingMore || !state.hasNext) return;
await fetchTagsPage(imageName, state.page + 1, false);
}
const response = await fetch(url);
if (response.ok) {
const tags = await response.json();
expandedImages = {
...expandedImages,
[imageName]: { loading: false, error: '', tags }
};
} else {
const data = await response.json();
expandedImages = {
...expandedImages,
[imageName]: { loading: false, error: data.error || 'Failed to fetch tags', tags: [] }
};
}
} catch (error: any) {
async function fetchTagsPage(imageName: string, page: number, isFirstLoad: boolean) {
const currentState = expandedImages[imageName];
expandedImages = {
...expandedImages,
[imageName]: {
loading: isFirstLoad,
loadingMore: !isFirstLoad,
error: '',
tags: currentState?.tags || [],
total: currentState?.total || 0,
page: currentState?.page || 0,
pageSize: 20,
hasNext: currentState?.hasNext || false
}
};
try {
let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}&page=${page}&pageSize=20`;
if (selectedRegistryId) {
url += `&registry=${selectedRegistryId}`;
}
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
const prevState = expandedImages[imageName];
const existingTags = isFirstLoad ? [] : (prevState?.tags || []);
expandedImages = {
...expandedImages,
[imageName]: { loading: false, error: error.message || 'Failed to fetch tags', tags: [] }
[imageName]: {
loading: false,
loadingMore: false,
error: '',
tags: [...existingTags, ...data.tags],
total: data.total,
page: data.page,
pageSize: data.pageSize,
hasNext: data.hasNext
}
};
} else {
const data = await response.json();
expandedImages = {
...expandedImages,
[imageName]: {
...expandedImages[imageName],
loading: false,
loadingMore: false,
error: data.error || 'Failed to fetch tags'
}
};
}
} catch (error: any) {
expandedImages = {
...expandedImages,
[imageName]: {
...expandedImages[imageName],
loading: false,
loadingMore: false,
error: error.message || 'Failed to fetch tags'
}
};
}
}
function handleTagsWheel(event: WheelEvent, imageName: string) {
const target = event.currentTarget as HTMLElement;
// Prevent page scroll when at top/bottom of tags list
const atTop = target.scrollTop === 0;
const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 1;
if ((atTop && event.deltaY < 0) || (atBottom && event.deltaY > 0)) {
event.preventDefault();
}
// Load more when near bottom
const state = expandedImages[imageName];
if (!state || !state.hasNext || state.loading || state.loadingMore) return;
if (target.scrollHeight - target.scrollTop - target.clientHeight < 50) {
loadMoreTags(imageName);
}
}
@@ -328,7 +395,8 @@
...expandedImages,
[imageName]: {
...state,
tags: state.tags.filter(t => t.name !== tag)
tags: state.tags.filter(t => t.name !== tag),
total: Math.max(0, state.total - 1)
}
};
}
@@ -514,9 +582,9 @@
{expandState.error}
</div>
{:else if expandState?.tags && expandState.tags.length > 0}
<div class="max-h-64 overflow-y-auto">
<div class="max-h-64 overflow-y-auto overscroll-contain" onwheel={(e) => handleTagsWheel(e, result.name)}>
<table class="text-xs">
<thead class="text-muted-foreground sticky top-0 bg-muted/50">
<thead class="text-muted-foreground sticky top-0 bg-background z-10">
<tr>
<th class="text-left py-1 px-2 pr-4 font-medium">Tag</th>
<th class="text-left py-1 px-2 pr-4 font-medium">Size</th>
@@ -590,7 +658,20 @@
{/each}
</tbody>
</table>
<!-- Loading more indicator -->
{#if expandState.loadingMore}
<div class="flex items-center justify-center py-2 text-xs text-muted-foreground">
<Loader2 class="w-3 h-3 animate-spin mr-2" />
Loading more...
</div>
{/if}
</div>
<!-- Tags count -->
{#if expandState.total > 0}
<div class="text-xs text-muted-foreground pt-1">
{expandState.tags.length} of {expandState.total} tags loaded
</div>
{/if}
{:else}
<div class="text-xs text-muted-foreground py-2">
No tags found
+9 -5
View File
@@ -3,7 +3,7 @@
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee } from 'lucide-svelte';
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database } from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs';
import { onMount, onDestroy } from 'svelte';
import { licenseStore } from '$lib/stores/license';
@@ -198,6 +198,7 @@
nodeVersion: string;
platform: string;
arch: string;
kernel: string;
memory: {
heapUsed: number;
heapTotal: number;
@@ -438,7 +439,7 @@
</div>
{/if}
{#if serverUptime !== null}
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 min-w-[8.5rem]">
<Clock class="w-3 h-3 shrink-0" />
<span class="tabular-nums">Uptime {formatUptime(serverUptime)}</span>
</div>
@@ -558,10 +559,13 @@
</span>
{/if}
<span class="text-muted-foreground/50">|</span>
<span class="text-muted-foreground">Platform</span>
<Server class="w-3 h-3 text-muted-foreground" />
<span>{systemInfo.runtime.platform}/{systemInfo.runtime.arch}</span>
<span class="text-muted-foreground/50">|</span>
<span class="text-muted-foreground">Memory</span>
<Cpu class="w-3 h-3 text-muted-foreground" />
<span>{systemInfo.runtime.kernel}</span>
<span class="text-muted-foreground/50">|</span>
<MemoryStick class="w-3 h-3 text-muted-foreground" />
<span>{formatBytes(systemInfo.runtime.memory.rss)}</span>
</div>
{#if systemInfo.runtime.container.inContainer}
@@ -585,7 +589,7 @@
<!-- Database Info -->
<div class="space-y-1.5">
<div class="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<HardDrive class="w-3.5 h-3.5" />
<Database class="w-3.5 h-3.5" />
Database
</div>
<div class="text-sm pl-5 space-y-1">
@@ -415,6 +415,10 @@
newLabelInput = '';
formPublicIp = environment.publicIp || '';
modalTab = 'general';
// Reset token state for this environment (important when switching between envs)
hawserToken = null;
generatedToken = null;
pendingToken = null;
// Load scanner settings, notifications, update check settings, and timezone
loadScannerSettings(environment.id);
loadEnvNotifications(environment.id);
@@ -825,6 +829,11 @@
}
}
// Reload only availability/versions without overwriting user's unsaved settings changes
async function reloadScannerAvailability(envId?: number) {
await loadScannerVersionsAsync(envId);
}
async function saveScannerSettings(envId?: number) {
try {
const response = await fetch('/api/settings/scanner', {
@@ -930,7 +939,8 @@
checkingGrypeUpdate = true;
grypeUpdateStatus = 'idle';
try {
const response = await fetch('/api/settings/scanner?checkUpdates=true');
const envParam = environment?.id ? `&env=${environment.id}` : '';
const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`);
const data = await response.json();
if (data.updates) {
grypeUpdateStatus = data.updates.grype?.hasUpdate ? 'update-available' : 'up-to-date';
@@ -947,7 +957,8 @@
checkingTrivyUpdate = true;
trivyUpdateStatus = 'idle';
try {
const response = await fetch('/api/settings/scanner?checkUpdates=true');
const envParam = environment?.id ? `&env=${environment.id}` : '';
const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`);
const data = await response.json();
if (data.updates) {
trivyUpdateStatus = data.updates.trivy?.hasUpdate ? 'update-available' : 'up-to-date';
@@ -1198,7 +1209,7 @@
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) focusFirstInput(); else onClose(); }}>
<Dialog.Content class="max-w-2xl min-h-[800px] max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Content class="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header class="flex-shrink-0 border-b pb-4">
<Dialog.Title class="flex items-center gap-2">
{#if !isEditing}
@@ -1362,7 +1373,7 @@
<HelpCircle class="w-3.5 h-3.5" />
</button>
</Popover.Trigger>
<Popover.Content class="w-80 text-sm" side="right">
<Popover.Content class="w-80 text-sm z-[200]" side="right">
<div class="space-y-3">
<div class="flex items-start gap-2">
<Unplug class="w-4 h-4 mt-0.5 text-cyan-500 shrink-0" />
@@ -2112,7 +2123,7 @@
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.grype}
<ImagePullProgressPopover imageName="anchore/grype:latest" envId={environment?.id} onComplete={() => loadScannerSettings(environment?.id)}>
<ImagePullProgressPopover imageName="anchore/grype:latest" envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
<Download class="w-2.5 h-2.5 mr-0.5" />
Pull
@@ -2189,7 +2200,7 @@
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.trivy}
<ImagePullProgressPopover imageName="aquasec/trivy:latest" envId={environment?.id} onComplete={() => loadScannerSettings(environment?.id)}>
<ImagePullProgressPopover imageName="aquasec/trivy:latest" envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
<Download class="w-2.5 h-2.5 mr-0.5" />
Pull
+127 -3
View File
@@ -8,8 +8,8 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe } from 'lucide-svelte';
import { appSettings, type DateFormat, type DownloadFormat } from '$lib/stores/settings';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock } from 'lucide-svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
@@ -32,6 +32,9 @@
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
let logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
let defaultTimezone = $derived($appSettings.defaultTimezone);
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
let eventPollInterval = $derived($appSettings.eventPollInterval);
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
const dateFormatOptions: { value: DateFormat; label: string; example: string }[] = [
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY', example: '31.12.2024' },
@@ -93,6 +96,27 @@
appSettings.setLogBufferSizeKb(value);
toast.success('Log buffer size updated');
}
function handleEventCollectionModeChange(value: string | undefined) {
if (value === 'stream' || value === 'poll') {
appSettings.setEventCollectionMode(value);
toast.success(`Event collection mode: ${value}`);
}
}
function handleEventPollIntervalChange(selected: { value: number } | undefined) {
if (selected?.value) {
appSettings.setEventPollInterval(selected.value);
toast.success(`Event poll interval: ${selected.value / 1000}s`);
}
}
function handleMetricsIntervalChange(selected: { value: number } | undefined) {
if (selected?.value) {
appSettings.setMetricsCollectionInterval(selected.value);
toast.success(`Metrics interval: ${selected.value / 1000}s`);
}
}
</script>
<div class="flex-1 min-h-0 overflow-y-auto">
@@ -362,7 +386,107 @@
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-1">
<div class="space-y-3">
<div>
<div class="flex items-center gap-2">
<Label>Activity event collection mode</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80">
<p class="text-xs">
<strong>Stream:</strong> Continuous event stream from Docker, instant notifications, higher CPU usage<br />
<strong>Poll:</strong> Periodic checks for new events, slight notification delay, lower CPU usage
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex items-center gap-4 mt-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="eventCollectionMode"
value="stream"
checked={(eventCollectionMode || 'stream') === 'stream'}
onchange={() => handleEventCollectionModeChange('stream')}
disabled={!$canAccess('settings', 'edit')}
class="accent-primary w-4 h-4"
/>
<Activity class="w-3.5 h-3.5" />
<span class="text-sm">Stream</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="eventCollectionMode"
value="poll"
checked={(eventCollectionMode || 'stream') === 'poll'}
onchange={() => handleEventCollectionModeChange('poll')}
disabled={!$canAccess('settings', 'edit')}
class="accent-primary w-4 h-4"
/>
<Clock class="w-3.5 h-3.5" />
<span class="text-sm">Poll</span>
</label>
<span class="text-xs text-muted-foreground {(eventCollectionMode || 'stream') === 'poll' ? '' : 'invisible'}">every</span>
<Select.Root
type="single"
value={String(eventPollInterval || 60000)}
onValueChange={(v) => v && handleEventPollIntervalChange({ value: parseInt(v) })}
disabled={!$canAccess('settings', 'edit') || (eventCollectionMode || 'stream') !== 'poll'}
>
<Select.Trigger class="w-24 h-8 {(eventCollectionMode || 'stream') === 'poll' ? '' : 'invisible'}">
{(eventPollInterval || 60000) === 30000 ? '30s' : (eventPollInterval || 60000) === 60000 ? '60s' : (eventPollInterval || 60000) === 120000 ? '120s' : '300s'}
</Select.Trigger>
<Select.Content>
<Select.Item value="30000">30s</Select.Item>
<Select.Item value="60000">60s</Select.Item>
<Select.Item value="120000">120s</Select.Item>
<Select.Item value="300000">300s</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-2">
<Label for="metrics-interval">Metrics collection interval</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80">
<p class="text-xs">
How often to collect CPU/memory metrics from running containers. Lower intervals
provide more frequent updates but increase CPU usage.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex items-center gap-2 mt-2">
<Select.Root
type="single"
value={String(metricsCollectionInterval || 30000)}
onValueChange={(v) => v && handleMetricsIntervalChange({ value: parseInt(v) })}
disabled={!$canAccess('settings', 'edit')}
>
<Select.Trigger class="w-24 h-8">
{(metricsCollectionInterval || 30000) === 10000 ? '10s' : (metricsCollectionInterval || 30000) === 30000 ? '30s' : (metricsCollectionInterval || 30000) === 60000 ? '60s' : '120s'}
</Select.Trigger>
<Select.Content>
<Select.Item value="10000">10s</Select.Item>
<Select.Item value="30000">30s</Select.Item>
<Select.Item value="60000">60s</Select.Item>
<Select.Item value="120000">120s</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
<Label for="schedule-retention">Schedule execution cleanup</Label>
<TogglePill
+67 -61
View File
@@ -8,12 +8,13 @@
import { Input } from '$lib/components/ui/input';
import * as Tooltip from '$lib/components/ui/tooltip';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, GitBranch, RefreshCw, Loader2, FileCode, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, ExternalLink, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw } from 'lucide-svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
import StackModal from './StackModal.svelte';
import GitStackModal from './GitStackModal.svelte';
import ImportStackModal from './ImportStackModal.svelte';
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
@@ -30,7 +31,7 @@
type SortDirection = 'asc' | 'desc';
let stacks = $state<ComposeStackInfo[]>([]);
let stackSources = $state<Record<string, { sourceType: string; repository?: any; gitStack?: any }>>({});
let stackSources = $state<Record<string, { sourceType: string; composePath?: string | null; repository?: any; gitStack?: any }>>({});
let stackEnvVarCounts = $state<Record<string, number>>({});
let gitStacks = $state<any[]>([]);
let gitRepositories = $state<any[]>([]);
@@ -43,6 +44,7 @@
let showCreateModal = $state(false);
let showEditModal = $state(false);
let showGitModal = $state(false);
let showImportModal = $state(false);
let editingStackName = $state('');
let editingGitStack = $state<any>(null);
let envId = $state<number | null>(null);
@@ -576,37 +578,24 @@
stackSources = sourcesData && !sourcesData.error ? sourcesData : {};
gitStacks = Array.isArray(gitStacksData) ? gitStacksData : [];
// Create a set of docker stack names for quick lookup
const dockerStackNames = new Set(dockerStacks.map((s: ComposeStackInfo) => s.name));
// Add undeployed git stacks as placeholder entries
const undeployedGitStacks: ComposeStackInfo[] = gitStacks
.filter((gs: any) => !dockerStackNames.has(gs.stackName))
.map((gs: any) => ({
name: gs.stackName,
status: 'not deployed',
containers: [],
containerDetails: [],
configFile: gs.composePath,
workingDir: ''
}));
// Add gitStack to all git-based stacks (both deployed and undeployed)
// Add gitStack details to all git-based stacks
// Note: The API already includes undeployed stacks from the database,
// so we just need to attach the gitStack object for additional metadata
for (const gs of gitStacks) {
if (!stackSources[gs.stackName]) {
// Undeployed git stack - create new source entry
// Git stack not in sources yet - create source entry
stackSources[gs.stackName] = {
sourceType: 'git',
repository: gs.repository,
gitStack: gs
};
} else if (stackSources[gs.stackName].sourceType === 'git') {
// Deployed git stack - add gitStack to existing source
// Git stack already in sources - add gitStack object
stackSources[gs.stackName].gitStack = gs;
}
}
stacks = [...dockerStacks, ...undeployedGitStacks];
stacks = dockerStacks;
// Fetch env var counts for internal and git stacks (in background, don't block UI)
const allStackNames = stacks.map(s => s.name);
@@ -1130,6 +1119,10 @@
<Plus class="w-3.5 h-3.5 mr-1" />
Create
</Button>
<Button size="sm" variant="outline" onclick={() => showImportModal = true}>
<Import class="w-3.5 h-3.5 mr-1" />
Adopt
</Button>
{/if}
</div>
</div>
@@ -1281,7 +1274,8 @@
{#snippet cell(column, stack, rowState)}
{@const source = getStackSource(stack.name)}
{#if column.id === 'name'}
{#if source.sourceType === 'internal'}
{#if source.sourceType !== 'git'}
<!-- Internal stacks (including those needing file location) are clickable -->
<button
type="button"
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left"
@@ -1290,6 +1284,7 @@
{stack.name}
</button>
{:else}
<!-- Git stacks open in GitStackModal instead -->
<span class="font-medium text-xs">{stack.name}</span>
{/if}
{#if stackEnvVarCounts[stack.name]}
@@ -1307,41 +1302,45 @@
{/if}
{:else if column.id === 'source'}
{#if source.sourceType === 'git'}
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 shadow-sm min-w-[5.5rem]"
title={source.repository ? `${source.repository.url} (${source.repository.branch})` : 'Deployed from Git repository'}
>
<GitBranch class="w-3 h-3" />
Git
</span>
{:else if source.sourceType === 'internal'}
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 shadow-sm min-w-[5.5rem]"
title="Managed by Dockhand"
>
<FileCode class="w-3 h-3" />
Internal
</span>
{:else}
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200 shadow-sm min-w-[5.5rem]"
title="File location unknown"
>
<ExternalLink class="w-3 h-3" />
Untracked
</span>
{/if}
{:else if column.id === 'location'}
{#if source.composePath}
{@const dirPath = source.composePath.replace(/\/[^/]+$/, '')}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 shadow-sm">
<GitBranch class="w-3 h-3" />
Git
<Tooltip.Trigger class="w-full text-left">
<span class="text-xs text-muted-foreground block truncate">
{dirPath}
</span>
</Tooltip.Trigger>
<Tooltip.Content>
{#if source.repository}
{source.repository.url} ({source.repository.branch})
{:else}
Deployed from Git repository
{/if}
<Tooltip.Content class="max-w-md">
<code class="text-xs">{source.composePath}</code>
</Tooltip.Content>
</Tooltip.Root>
{:else if source.sourceType === 'internal'}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 shadow-sm">
<FileCode class="w-3 h-3" />
Internal
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">Created in Dockhand</Tooltip.Content>
</Tooltip.Root>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200 shadow-sm">
<ExternalLink class="w-3 h-3" />
External
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">Created outside Dockhand</Tooltip.Content>
</Tooltip.Root>
<span class="text-xs text-muted-foreground/50">—</span>
{/if}
{:else if column.id === 'containers'}
<div class="flex items-center gap-1">
@@ -1501,16 +1500,7 @@
</GitDeployProgressPopover>
{/if}
{#if $canAccess('stacks', 'edit')}
{#if source.sourceType === 'internal'}
<button
type="button"
onclick={() => editStack(stack.name)}
title="Edit"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
{:else if source.sourceType === 'git' && source.gitStack}
{#if source.sourceType === 'git' && source.gitStack}
<button
type="button"
onclick={() => openGitModal(source.gitStack)}
@@ -1519,6 +1509,16 @@
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-purple-500" />
</button>
{:else}
<!-- Internal stacks (including those needing file location) -->
<button
type="button"
onclick={() => editStack(stack.name)}
title="Edit"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{/if}
{#if stack.containers && stack.containers.length > 0}
@@ -1947,6 +1947,12 @@
onSaved={fetchStacks}
/>
<ImportStackModal
bind:open={showImportModal}
onClose={() => showImportModal = false}
onAdopted={fetchStacks}
/>
<ContainerInspectModal
bind:open={showInspectModal}
containerId={inspectContainerId}
@@ -175,7 +175,7 @@
{@render children()}
</Popover.Trigger>
<Popover.Content
class="w-80 p-0 overflow-hidden flex flex-col"
class="w-80 p-0 overflow-hidden flex flex-col z-[200]"
align="end"
sideOffset={8}
interactOutsideBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -50,7 +50,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">
<HardDrive class="w-5 h-5" />
+1 -1
View File
@@ -48,7 +48,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl h-[90vh] flex flex-col">
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<HardDrive class="w-5 h-5" />