Files
dockhand/routes/stacks/+page.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

1935 lines
71 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
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 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 GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
import { canAccess } from '$lib/stores/auth';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
import type { DataGridSortState } from '$lib/components/data-grid/types';
type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory';
type SortDirection = 'asc' | 'desc';
let stacks = $state<ComposeStackInfo[]>([]);
let stackSources = $state<Record<string, { sourceType: string; repository?: any; gitStack?: any }>>({});
let stackEnvVarCounts = $state<Record<string, number>>({});
let gitStacks = $state<any[]>([]);
let gitRepositories = $state<any[]>([]);
let gitCredentials = $state<any[]>([]);
let containerStats = $state<Map<string, ContainerStats>>(new Map());
let containerStatsHistory = $state<Map<string, { cpu: number[]; mem: number[]; netRx: number[]; netTx: number[]; diskR: number[]; diskW: number[] }>>(new Map());
let statsUpdateCount = $state(0); // Force reactivity counter
const MAX_HISTORY = 20;
let loading = $state(true);
let showCreateModal = $state(false);
let showEditModal = $state(false);
let showGitModal = $state(false);
let editingStackName = $state('');
let editingGitStack = $state<any>(null);
let envId = $state<number | null>(null);
// Derived: current environment details for reactive port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
// Helper: extract host from URL (e.g., tcp://192.168.1.4:2376 -> 192.168.1.4)
function extractHostFromUrl(urlString: string): string | null {
if (!urlString) return null;
try {
// Handle tcp:// and other protocols
const normalized = urlString.replace(/^tcp:\/\//, 'http://');
const url = new URL(normalized);
return url.hostname;
} catch {
// Try regex fallback for non-standard URLs
const match = urlString.match(/(?:\/\/)?([^:/]+)/);
return match?.[1] || null;
}
}
// Helper: get clickable URL for a port
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;
}
// Helper: format uptime from status string
function formatUptime(status: string): string {
if (!status) return '-';
const upMatch = status.match(/Up\s+(.+?)(?:\s+\(|$)/i);
if (upMatch) return upMatch[1].trim();
const exitMatch = status.match(/Exited.+?(\d+\s+\w+)\s+ago/i);
if (exitMatch) return exitMatch[1] + ' ago';
return '-';
}
// Helper: get container's primary IP address
function getContainerIp(networks: Array<{ name: string; ipAddress: string }>): string {
if (!networks || networks.length === 0) return '-';
return networks[0]?.ipAddress || '-';
}
// Helper: format bytes to human readable
function formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0B';
const k = 1024;
const sizes = ['B', 'K', 'M', 'G', 'T'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
}
// Fetch container stats
async function fetchStats() {
try {
const response = await fetch(appendEnvParam('/api/containers/stats', envId));
const stats: ContainerStats[] = await response.json();
const statsMap = new Map<string, ContainerStats>();
const newHistory = new Map(containerStatsHistory);
for (const stat of stats) {
if (!stat.id) continue;
statsMap.set(stat.id, stat);
// Update history for all metrics
const history = newHistory.get(stat.id) || { cpu: [], mem: [], netRx: [], netTx: [], diskR: [], diskW: [] };
history.cpu = [...history.cpu.slice(-(MAX_HISTORY - 1)), stat.cpuPercent];
history.mem = [...history.mem.slice(-(MAX_HISTORY - 1)), stat.memoryPercent];
history.netRx = [...history.netRx.slice(-(MAX_HISTORY - 1)), stat.networkRx];
history.netTx = [...history.netTx.slice(-(MAX_HISTORY - 1)), stat.networkTx];
history.diskR = [...history.diskR.slice(-(MAX_HISTORY - 1)), stat.blockRead];
history.diskW = [...history.diskW.slice(-(MAX_HISTORY - 1)), stat.blockWrite];
newHistory.set(stat.id, history);
}
containerStats = statsMap;
containerStatsHistory = newHistory;
statsUpdateCount++; // Trigger reactivity for expanded rows
} catch (error) {
console.error('Failed to fetch container stats:', error);
}
}
// Generate sparkline SVG path
function generateSparklinePath(data: number[], width: number, height: number): string {
if (data.length < 2) return '';
const max = Math.max(...data, 1);
const step = width / (data.length - 1);
return data.map((val, i) => {
const x = i * step;
const y = height - (val / max) * height;
return i === 0 ? `M ${x} ${y}` : `L ${x} ${y}`;
}).join(' ');
}
// Generate area path for sparkline fill
function generateAreaPath(data: number[], width: number, height: number): string {
if (data.length < 2) return '';
const max = Math.max(...data, 1);
const step = width / (data.length - 1);
const line = data.map((val, i) => {
const x = i * step;
const y = height - (val / max) * height;
return i === 0 ? `M ${x} ${y}` : `L ${x} ${y}`;
}).join(' ');
return `${line} L ${width} ${height} L 0 ${height} Z`;
}
// Aggregate stats for a stack (sum of all running containers)
interface StackStats {
cpuPercent: number;
memoryUsage: number;
memoryLimit: number;
networkRx: number;
networkTx: number;
blockRead: number;
blockWrite: number;
runningCount: number;
}
function getStackStats(stack: ComposeStackInfo): StackStats | null {
if (!stack.containerDetails || stack.containerDetails.length === 0) return null;
let cpuPercent = 0;
let memoryUsage = 0;
let memoryLimit = 0;
let networkRx = 0;
let networkTx = 0;
let blockRead = 0;
let blockWrite = 0;
let runningCount = 0;
for (const container of stack.containerDetails) {
// Only aggregate stats from running containers
if (container.state !== 'running') continue;
const stats = containerStats.get(container.id);
if (stats) {
cpuPercent += stats.cpuPercent;
memoryUsage += stats.memoryUsage;
memoryLimit += stats.memoryLimit;
networkRx += stats.networkRx;
networkTx += stats.networkTx;
blockRead += stats.blockRead;
blockWrite += stats.blockWrite;
runningCount++;
}
}
if (runningCount === 0) return null;
return { cpuPercent, memoryUsage, memoryLimit, networkRx, networkTx, blockRead, blockWrite, runningCount };
}
// Search and sort state
let searchInput = $state('');
let searchQuery = $state('');
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
// Status filter state
const STATUS_FILTER_STORAGE_KEY = 'dockhand-stacks-status-filter';
let statusFilter = $state<string[]>([]);
// Stack status types with icons and colors
const stackStatusTypes = [
{ value: 'running', label: 'Running', icon: Play, color: 'text-emerald-500' },
{ value: 'partial', label: 'Partial', icon: CircleDashed, color: 'text-amber-500' },
{ value: 'stopped', label: 'Stopped', icon: Square, color: 'text-rose-500' },
{ value: 'not deployed', label: 'Not deployed', icon: Rocket, color: 'text-violet-500' }
];
function getStackStatusIcon(status: string) {
const s = stackStatusTypes.find(t => t.value === status.toLowerCase());
return s?.icon || Layers;
}
function loadStatusFilter() {
try {
const stored = localStorage.getItem(STATUS_FILTER_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
statusFilter = parsed;
}
}
} catch {
// Ignore localStorage errors
}
}
function saveStatusFilter() {
try {
localStorage.setItem(STATUS_FILTER_STORAGE_KEY, JSON.stringify(statusFilter));
} catch {
// Ignore localStorage errors
}
}
// Auto-save status filter changes
$effect(() => {
const filter = statusFilter;
saveStatusFilter();
});
// Confirmation popover state
let confirmDeleteName = $state<string | null>(null);
let confirmStopName = $state<string | null>(null);
let confirmDownName = $state<string | null>(null);
// Stack operation loading state
let stackActionLoading = $state<string | null>(null);
// Container-level confirmation popover state
let confirmStopContainerId = $state<string | null>(null);
let confirmRestartContainerId = $state<string | null>(null);
let confirmPauseContainerId = $state<string | null>(null);
// Operation error state (for stack and container operations)
let operationError = $state<{ id: string; message: string } | null>(null);
let errorTimeouts: ReturnType<typeof setTimeout>[] = [];
function clearErrorAfterDelay(id: string) {
const timeoutId = setTimeout(() => {
if (operationError?.id === id) operationError = null;
}, 5000);
errorTimeouts.push(timeoutId);
}
// Container inspect modal state
let showInspectModal = $state(false);
let inspectContainerId = $state('');
let inspectContainerName = $state('');
// File browser modal state
let showFileBrowserModal = $state(false);
let fileBrowserContainerId = $state('');
let fileBrowserContainerName = $state('');
// Multi-select state
let selectedStacks = $state<Set<string>>(new Set());
let confirmBulkStart = $state(false);
let confirmBulkStop = $state(false);
let confirmBulkDown = $state(false);
let confirmBulkRestart = $state(false);
let confirmBulkRemove = $state(false);
// Batch operation modal state
let showBatchOpModal = $state(false);
let batchOpTitle = $state('');
let batchOpOperation = $state('');
let batchOpItems = $state<Array<{ id: string; name: string }>>([]);
// Filtered and sorted stacks
const filteredStacks = $derived.by(() => {
let result = stacks;
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(stack =>
stack.name.toLowerCase().includes(query) ||
stack.status.toLowerCase().includes(query)
);
}
// Filter by status
if (statusFilter.length > 0) {
result = result.filter(stack => statusFilter.includes(stack.status.toLowerCase()));
}
// Sort
result = [...result].sort((a, b) => {
let cmp = 0;
switch (sortField) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'containers':
cmp = a.containers.length - b.containers.length;
break;
case 'status':
cmp = a.status.localeCompare(b.status);
break;
case 'cpu':
const cpuA = getStackStats(a)?.cpuPercent ?? -1;
const cpuB = getStackStats(b)?.cpuPercent ?? -1;
cmp = cpuA - cpuB;
break;
case 'memory':
const memA = getStackStats(a)?.memoryUsage ?? -1;
const memB = getStackStats(b)?.memoryUsage ?? -1;
cmp = memA - memB;
break;
}
// Secondary sort by name for stability when primary values are equal
if (cmp === 0 && sortField !== 'name') {
cmp = a.name.localeCompare(b.name);
}
return sortDirection === 'asc' ? cmp : -cmp;
});
return result;
});
// Check if all filtered stacks are selected
const allFilteredSelected = $derived(
filteredStacks.length > 0 && filteredStacks.every(s => selectedStacks.has(s.name))
);
const someFilteredSelected = $derived(
filteredStacks.some(s => selectedStacks.has(s.name)) && !allFilteredSelected
);
const selectedInFilter = $derived(
filteredStacks.filter(s => selectedStacks.has(s.name))
);
// Count by status for selected stacks
const selectedRunning = $derived(selectedInFilter.filter(s => s.status === 'running' || s.status === 'partial'));
const selectedStopped = $derived(selectedInFilter.filter(s => s.status === 'stopped' || s.status === 'not deployed'));
function toggleSelectAll() {
if (allFilteredSelected) {
filteredStacks.forEach(s => selectedStacks.delete(s.name));
} else {
filteredStacks.forEach(s => selectedStacks.add(s.name));
}
selectedStacks = new Set(selectedStacks);
}
function selectNone() {
selectedStacks = new Set();
}
function toggleStackSelection(stackName: string) {
if (selectedStacks.has(stackName)) {
selectedStacks.delete(stackName);
} else {
selectedStacks.add(stackName);
}
selectedStacks = new Set(selectedStacks);
}
function bulkStart() {
batchOpTitle = `Starting ${selectedStopped.length} stack${selectedStopped.length !== 1 ? 's' : ''}`;
batchOpOperation = 'start';
batchOpItems = selectedStopped.map(s => ({ id: s.name, name: s.name }));
showBatchOpModal = true;
}
function bulkStop() {
batchOpTitle = `Stopping ${selectedRunning.length} stack${selectedRunning.length !== 1 ? 's' : ''}`;
batchOpOperation = 'stop';
batchOpItems = selectedRunning.map(s => ({ id: s.name, name: s.name }));
showBatchOpModal = true;
}
function bulkDown() {
batchOpTitle = `Bringing down ${selectedRunning.length} stack${selectedRunning.length !== 1 ? 's' : ''}`;
batchOpOperation = 'down';
batchOpItems = selectedRunning.map(s => ({ id: s.name, name: s.name }));
showBatchOpModal = true;
}
function bulkRestart() {
batchOpTitle = `Restarting ${selectedRunning.length} stack${selectedRunning.length !== 1 ? 's' : ''}`;
batchOpOperation = 'restart';
batchOpItems = selectedRunning.map(s => ({ id: s.name, name: s.name }));
showBatchOpModal = true;
}
function bulkRemove() {
batchOpTitle = `Removing ${selectedInFilter.length} stack${selectedInFilter.length !== 1 ? 's' : ''}`;
batchOpOperation = 'remove';
batchOpItems = selectedInFilter.map(s => ({ id: s.name, name: s.name }));
showBatchOpModal = true;
}
function handleBatchComplete() {
selectedStacks = new Set();
fetchStacks();
}
// Expanded rows state - load from localStorage
const EXPANDED_STORAGE_KEY = 'dockhand-stacks-expanded';
let expandedStacks = $state<Set<string>>(new Set());
// Load expanded state from localStorage on init
function loadExpandedState() {
try {
const stored = localStorage.getItem(EXPANDED_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
expandedStacks = new Set(parsed);
}
}
} catch {
// Ignore localStorage errors
}
}
// Save expanded state to localStorage
function saveExpandedState() {
try {
localStorage.setItem(EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(expandedStacks)));
} catch {
// Ignore localStorage errors
}
}
function toggleExpand(stackName: string) {
if (expandedStacks.has(stackName)) {
expandedStacks.delete(stackName);
} else {
expandedStacks.add(stackName);
}
expandedStacks = new Set(expandedStacks);
saveExpandedState();
}
function expandAll() {
expandedStacks = new Set(stacks.map(s => s.name));
saveExpandedState();
}
function collapseAll() {
expandedStacks = new Set();
saveExpandedState();
}
// Check if all stacks are expanded
const allExpanded = $derived(stacks.length > 0 && stacks.every(s => expandedStacks.has(s.name)));
const someExpanded = $derived(expandedStacks.size > 0);
// Debounce search input
let searchTimeout: ReturnType<typeof setTimeout>;
$effect(() => {
const input = searchInput; // Track dependency
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = input;
}, 200);
return () => clearTimeout(searchTimeout);
});
// Track last loaded environment to show skeleton on environment change
let lastLoadedEnvId = $state<number | null>(null);
// Track if initial fetch has been done
let initialFetchDone = $state(false);
// Subscribe to environment changes using $effect
$effect(() => {
const env = $currentEnvironment;
const newEnvId = env?.id ?? null;
// Only fetch if environment actually changed or this is initial load
if (env && (newEnvId !== envId || !initialFetchDone)) {
envId = newEnvId;
initialFetchDone = true;
fetchStacks();
fetchStats();
} else if (!env) {
// No environment - clear data and stop loading
envId = null;
stacks = [];
containerStats = new Map();
loading = false;
}
});
async function fetchStacks() {
// Show loading skeleton on initial load or when environment changes, but not on refreshes
if (lastLoadedEnvId !== envId) {
loading = true;
}
try {
const [stacksRes, sourcesRes, gitStacksRes] = await Promise.all([
fetch(appendEnvParam('/api/stacks', envId)),
fetch(appendEnvParam('/api/stacks/sources', envId)),
fetch(appendEnvParam('/api/git/stacks', envId))
]);
// Handle stale environment ID (e.g., after database reset)
if (stacksRes.status === 404 && envId) {
console.warn(`[Stacks] Got 404 for env ${envId}, refreshing environments`);
clearStaleEnvironment(envId);
environments.refresh();
return;
}
const dockerStacks = await stacksRes.json();
const sourcesData = await sourcesRes.json();
const gitStacksData = await gitStacksRes.json();
// Debug logging
if (gitStacksData?.error) {
console.error('Git stacks API error:', gitStacksData.error, 'Status:', gitStacksRes.status);
}
// Ensure responses are valid before using them
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)
for (const gs of gitStacks) {
if (!stackSources[gs.stackName]) {
// Undeployed git stack - create new 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
stackSources[gs.stackName].gitStack = gs;
}
}
stacks = [...dockerStacks, ...undeployedGitStacks];
// Fetch env var counts for internal and git stacks (in background, don't block UI)
const allStackNames = stacks.map(s => s.name);
fetchEnvVarCounts(allStackNames, sourcesData);
} catch (error) {
console.error('Failed to fetch stacks:', error);
toast.error('Failed to load stacks');
} finally {
loading = false;
lastLoadedEnvId = envId;
}
}
async function fetchEnvVarCounts(stackNames: string[], sources: Record<string, any>) {
// Only fetch for stacks that can have env vars (internal or git)
const stacksToFetch = stackNames.filter(name => {
const source = sources[name];
return source && (source.sourceType === 'internal' || source.sourceType === 'git');
});
if (stacksToFetch.length === 0) {
stackEnvVarCounts = {};
return;
}
const counts: Record<string, number> = {};
// Fetch in parallel with error handling
await Promise.all(stacksToFetch.map(async (stackName) => {
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId));
if (response.ok) {
const data = await response.json();
const varCount = data.variables?.length || 0;
if (varCount > 0) {
counts[stackName] = varCount;
}
}
} catch (e) {
// Ignore errors for individual stack env var fetches
}
}));
stackEnvVarCounts = counts;
}
function getStackSource(stackName: string) {
return stackSources[stackName] || { sourceType: 'external' };
}
async function openGitModal(gitStack?: any) {
editingGitStack = gitStack || null;
// Fetch repositories and credentials before opening modal
try {
const [reposRes, credsRes] = await Promise.all([
fetch('/api/git/repositories'),
fetch('/api/git/credentials')
]);
gitRepositories = await reposRes.json();
gitCredentials = await credsRes.json();
} catch (error) {
console.error('Failed to fetch git data:', error);
gitRepositories = [];
gitCredentials = [];
}
showGitModal = true;
}
async function startStack(name: string) {
operationError = null;
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to start stack' };
toast.error(`Failed to start ${name}`);
clearErrorAfterDelay(name);
return;
}
toast.success(`Started ${name}`);
await fetchStacks();
} catch (error) {
console.error('Failed to start stack:', error);
operationError = { id: name, message: 'Failed to start stack' };
toast.error(`Failed to start ${name}`);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
}
}
async function stopStack(name: string) {
operationError = null;
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to stop stack' };
toast.error(`Failed to stop ${name}`);
clearErrorAfterDelay(name);
return;
}
toast.success(`Stopped ${name}`);
await fetchStacks();
} catch (error) {
console.error('Failed to stop stack:', error);
operationError = { id: name, message: 'Failed to stop stack' };
toast.error(`Failed to stop ${name}`);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
}
}
async function restartStack(name: string) {
operationError = null;
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to restart stack' };
toast.error(`Failed to restart ${name}`);
clearErrorAfterDelay(name);
return;
}
toast.success(`Restarted ${name}`);
await fetchStacks();
} catch (error) {
console.error('Failed to restart stack:', error);
operationError = { id: name, message: 'Failed to restart stack' };
toast.error(`Failed to restart ${name}`);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
}
}
async function downStack(name: string) {
operationError = null;
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to bring down stack' };
toast.error(`Failed to bring down ${name}`);
clearErrorAfterDelay(name);
return;
}
toast.success(`Brought down ${name}`);
await fetchStacks();
} catch (error) {
console.error('Failed to bring down stack:', error);
operationError = { id: name, message: 'Failed to bring down stack' };
toast.error(`Failed to bring down ${name}`);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
}
}
function viewStackLogs(stack: ComposeStackInfo) {
// Navigate to logs page with all containers from the stack (grouped mode)
// Use containerDetails for full info, or fall back to containers (which is already string[])
const containerIds = stack.containerDetails
?.map(c => c.id)
.filter((id): id is string => !!id)
.join(',') || stack.containers?.filter(Boolean).join(',');
if (containerIds) {
const url = appendEnvParam(`/logs?containers=${containerIds}&stack=${encodeURIComponent(stack.name)}`, envId);
goto(url);
}
}
async function removeStack(name: string) {
operationError = null;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}?force=true`, envId), { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to remove stack' };
toast.error(`Failed to remove ${name}`);
clearErrorAfterDelay(name);
return;
}
toast.success(`Removed ${name}`);
await fetchStacks();
} catch (error) {
console.error('Failed to remove stack:', error);
operationError = { id: name, message: 'Failed to remove stack' };
toast.error(`Failed to remove ${name}`);
clearErrorAfterDelay(name);
}
}
function editStack(name: string) {
editingStackName = name;
showEditModal = true;
}
function getStatusClasses(status: string): string {
const base = 'text-xs px-1.5 py-0.5 rounded-sm font-medium inline-flex items-center gap-1 w-[6rem] justify-center shadow-sm whitespace-nowrap';
switch (status.toLowerCase()) {
case 'running':
return `${base} bg-emerald-200 dark:bg-emerald-800 text-emerald-900 dark:text-emerald-100`;
case 'stopped':
return `${base} bg-red-200 dark:bg-red-800 text-red-900 dark:text-red-100`;
case 'partial':
return `${base} bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100`;
case 'not deployed':
return `${base} bg-violet-200 dark:bg-violet-800 text-violet-900 dark:text-violet-100`;
default:
return `${base} bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-slate-100`;
}
}
function toggleSort(field: SortField) {
if (sortField === field) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDirection = field === 'containers' ? 'desc' : 'asc';
}
}
// Container actions
let containerActionLoading = $state<string | null>(null);
async function startContainer(containerId: string, e: Event) {
e.stopPropagation();
operationError = null;
containerActionLoading = containerId;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/start`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to start container' };
toast.error('Failed to start container');
clearErrorAfterDelay(containerId);
return;
}
toast.success('Container started');
await fetchStacks();
} catch (error) {
console.error('Failed to start container:', error);
operationError = { id: containerId, message: 'Failed to start container' };
toast.error('Failed to start container');
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
}
}
async function stopContainer(containerId: string) {
operationError = null;
containerActionLoading = containerId;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/stop`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to stop container' };
toast.error('Failed to stop container');
clearErrorAfterDelay(containerId);
return;
}
toast.success('Container stopped');
await fetchStacks();
} catch (error) {
console.error('Failed to stop container:', error);
operationError = { id: containerId, message: 'Failed to stop container' };
toast.error('Failed to stop container');
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
}
}
async function restartContainer(containerId: string) {
operationError = null;
containerActionLoading = containerId;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/restart`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to restart container' };
toast.error('Failed to restart container');
clearErrorAfterDelay(containerId);
return;
}
toast.success('Container restarted');
await fetchStacks();
} catch (error) {
console.error('Failed to restart container:', error);
operationError = { id: containerId, message: 'Failed to restart container' };
toast.error('Failed to restart container');
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
}
}
async function pauseContainer(containerId: string) {
operationError = null;
containerActionLoading = containerId;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/pause`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to pause container' };
toast.error('Failed to pause container');
clearErrorAfterDelay(containerId);
return;
}
toast.success('Container paused');
await fetchStacks();
} catch (error) {
console.error('Failed to pause container:', error);
operationError = { id: containerId, message: 'Failed to pause container' };
toast.error('Failed to pause container');
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
}
}
async function unpauseContainer(containerId: string, e: Event) {
e.stopPropagation();
operationError = null;
containerActionLoading = containerId;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/unpause`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to unpause container' };
toast.error('Failed to unpause container');
clearErrorAfterDelay(containerId);
return;
}
toast.success('Container unpaused');
await fetchStacks();
} catch (error) {
console.error('Failed to unpause container:', error);
operationError = { id: containerId, message: 'Failed to unpause container' };
toast.error('Failed to unpause container');
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
}
}
function inspectContainer(containerId: string, containerName: string) {
inspectContainerId = containerId;
inspectContainerName = containerName;
showInspectModal = true;
}
function browseFiles(containerId: string, containerName: string) {
fileBrowserContainerId = containerId;
fileBrowserContainerName = containerName;
showFileBrowserModal = true;
}
function getHealthClasses(health: string | undefined): string {
switch (health) {
case 'healthy':
return 'text-emerald-500';
case 'unhealthy':
return 'text-red-500';
case 'starting':
return 'text-amber-500';
default:
return 'text-muted-foreground';
}
}
function getContainerStateCounts(stack: ComposeStackInfo): Record<string, number> {
const counts: Record<string, number> = {};
if (stack.containerDetails) {
for (const container of stack.containerDetails) {
const state = container.state.toLowerCase();
counts[state] = (counts[state] || 0) + 1;
}
}
return counts;
}
function getStackNetworkCount(stack: ComposeStackInfo): number {
if (!stack.containerDetails) return 0;
const uniqueNetworks = new Set<string>();
for (const container of stack.containerDetails) {
for (const network of container.networks) {
uniqueNetworks.add(network.name);
}
}
return uniqueNetworks.size;
}
function getStackVolumeCount(stack: ComposeStackInfo): number {
if (!stack.containerDetails) return 0;
return stack.containerDetails.reduce((sum, c) => sum + c.volumeCount, 0);
}
// Handle tab visibility changes (e.g., user switches back from another tab)
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && envId) {
fetchStacks();
fetchStats();
}
}
onMount(() => {
loadExpandedState();
loadStatusFilter();
// Initial fetch is handled by $effect - no need to duplicate here
// Listen for tab visibility changes to refresh when user returns
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('resume', handleVisibilityChange);
// Subscribe to container events (stacks are identified by container labels)
const unsubscribe = onDockerEvent((event) => {
if (envId && isContainerListChange(event)) {
fetchStacks();
fetchStats();
}
});
// Refresh stacks every 30 seconds
const stacksInterval = setInterval(() => {
if (envId) fetchStacks();
}, 30000);
// Refresh stats every 5 seconds (faster for resource monitoring)
const statsInterval = setInterval(() => {
if (envId) fetchStats();
}, 5000);
return () => {
clearInterval(stacksInterval);
clearInterval(statsInterval);
unsubscribe();
};
});
onDestroy(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
errorTimeouts.forEach(id => clearTimeout(id));
errorTimeouts = [];
});
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<PageHeader icon={Layers} title="Compose stacks" count={stacks.length}>
{#if stacks.length > 0}
<button
type="button"
onclick={allExpanded ? collapseAll : expandAll}
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-border hover:border-foreground/30 hover:shadow-sm transition-all cursor-pointer text-muted-foreground hover:text-foreground"
title={allExpanded ? 'Collapse all' : 'Expand all'}
>
{#if allExpanded}
<ChevronsDownUp class="w-3 h-3" />
Collapse
{:else}
<ChevronsUpDown class="w-3 h-3" />
Expand
{/if}
</button>
{/if}
</PageHeader>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search stacks..."
bind:value={searchInput}
onkeydown={(e) => e.key === 'Escape' && (searchInput = '')}
class="pl-8 h-8 w-48 text-sm"
/>
</div>
<MultiSelectFilter
bind:value={statusFilter}
options={stackStatusTypes}
placeholder="All statuses"
pluralLabel="statuses"
width="w-44"
defaultIcon={Layers}
/>
<Button size="sm" variant="outline" onclick={fetchStacks}>
<RefreshCw class="w-3.5 h-3.5 mr-1" />
Refresh
</Button>
{#if $canAccess('stacks', 'create')}
<Button size="sm" variant="outline" onclick={() => openGitModal()}>
<GitBranch class="w-3.5 h-3.5 mr-1" />
From Git
</Button>
<Button size="sm" variant="secondary" onclick={() => showCreateModal = true}>
<Plus class="w-3.5 h-3.5 mr-1" />
Create
</Button>
{/if}
</div>
</div>
<!-- Selection bar -->
{#if selectedStacks.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
</button>
{#if selectedStopped.length > 0 && $canAccess('stacks', 'start')}
<ConfirmPopover
open={confirmBulkStart}
action="Start"
itemType="stacks"
itemName="{selectedStopped.length} stack{selectedStopped.length !== 1 ? 's' : ''}"
title="Start {selectedStopped.length}"
variant="secondary"
unstyled
onConfirm={bulkStart}
onOpenChange={(open) => confirmBulkStart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer">
<Play class="w-3 h-3" />
Start
</span>
{/snippet}
</ConfirmPopover>
{/if}
{#if selectedRunning.length > 0 && $canAccess('stacks', 'restart')}
<ConfirmPopover
open={confirmBulkRestart}
action="Restart"
itemType="stacks"
itemName="{selectedRunning.length} stack{selectedRunning.length !== 1 ? 's' : ''}"
title="Restart {selectedRunning.length}"
variant="secondary"
unstyled
onConfirm={bulkRestart}
onOpenChange={(open) => confirmBulkRestart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-amber-600 hover:border-amber-500/40 hover:shadow transition-all cursor-pointer">
<RotateCcw class="w-3 h-3" />
Restart
</span>
{/snippet}
</ConfirmPopover>
{/if}
{#if selectedRunning.length > 0 && $canAccess('stacks', 'stop')}
<ConfirmPopover
open={confirmBulkStop}
action="Stop"
itemType="stacks"
itemName="{selectedRunning.length} stack{selectedRunning.length !== 1 ? 's' : ''}"
title="Stop {selectedRunning.length}"
unstyled
onConfirm={bulkStop}
onOpenChange={(open) => confirmBulkStop = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer">
<Square class="w-3 h-3" />
Stop
</span>
{/snippet}
</ConfirmPopover>
{/if}
{#if selectedRunning.length > 0 && $canAccess('stacks', 'stop')}
<ConfirmPopover
open={confirmBulkDown}
action="Down"
itemType="stacks"
itemName="{selectedRunning.length} stack{selectedRunning.length !== 1 ? 's' : ''}"
title="Down {selectedRunning.length}"
unstyled
onConfirm={bulkDown}
onOpenChange={(open) => confirmBulkDown = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-orange-600 hover:border-orange-500/40 hover:shadow transition-all cursor-pointer">
<ArrowBigDown class="w-3 h-3" />
Down
</span>
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('stacks', 'remove')}
<ConfirmPopover
open={confirmBulkRemove}
action="Remove"
itemType="stacks"
itemName="{selectedInFilter.length} stack{selectedInFilter.length !== 1 ? 's' : ''}"
title="Remove {selectedInFilter.length}"
unstyled
onConfirm={bulkRemove}
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<Trash2 class="w-3 h-3" />
Remove
</span>
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
{:else if !loading && stacks.length === 0}
<EmptyState
icon={Layers}
title="No compose stacks found"
description="Create a stack or deploy from Git to get started"
/>
{:else}
<DataGrid
data={filteredStacks}
keyField="name"
gridId="stacks"
loading={loading}
selectable
bind:selectedKeys={selectedStacks}
expandable
bind:expandedKeys={expandedStacks}
onExpandChange={(key, expanded) => saveExpandedState()}
sortState={{ field: sortField, direction: sortDirection }}
onSortChange={(state) => {
sortField = state.field as SortField;
sortDirection = state.direction;
}}
onRowClick={(stack, e) => {
const hasContainers = stack.containers && stack.containers.length > 0;
if (hasContainers) {
toggleExpand(stack.name);
}
}}
rowClass={(stack) => {
const isExp = expandedStacks.has(stack.name);
const isSel = selectedStacks.has(stack.name);
return `${isExp ? 'bg-muted/40' : ''} ${isSel ? 'bg-muted/30' : ''}`;
}}
>
{#snippet cell(column, stack, rowState)}
{@const source = getStackSource(stack.name)}
{#if column.id === 'name'}
<span class="font-medium text-xs">{stack.name}</span>
{#if stackEnvVarCounts[stack.name]}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 ml-1.5 text-2xs px-1 py-0.5 rounded bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300">
<Variable class="w-2.5 h-2.5" />
{stackEnvVarCounts[stack.name]}
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">
{stackEnvVarCounts[stack.name]} environment variable{stackEnvVarCounts[stack.name] !== 1 ? 's' : ''} configured
</Tooltip.Content>
</Tooltip.Root>
{/if}
{:else if column.id === 'source'}
{#if source.sourceType === 'git'}
<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
</span>
</Tooltip.Trigger>
<Tooltip.Content>
{#if source.repository}
{source.repository.url} ({source.repository.branch})
{:else}
Deployed from Git repository
{/if}
</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>
{/if}
{:else if column.id === 'containers'}
<div class="flex items-center gap-1">
{#if getContainerStateCounts(stack).running}
<span class="inline-flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400" title="Running">
<Play class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).running}</span>
</span>
{/if}
{#if getContainerStateCounts(stack).exited}
<span class="inline-flex items-center gap-0.5 text-red-600 dark:text-red-400" title="Exited">
<Square class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).exited}</span>
</span>
{/if}
{#if getContainerStateCounts(stack).paused}
<span class="inline-flex items-center gap-0.5 text-amber-600 dark:text-amber-400" title="Paused">
<Pause class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).paused}</span>
</span>
{/if}
{#if getContainerStateCounts(stack).restarting}
<span class="inline-flex items-center gap-0.5 text-blue-600 dark:text-blue-400" title="Restarting">
<span class="w-3.5 h-3.5 flex items-center justify-center"><RefreshCw class="w-3.5 h-3.5 animate-spin" /></span>
<span class="text-xs font-medium">{getContainerStateCounts(stack).restarting}</span>
</span>
{/if}
{#if getContainerStateCounts(stack).created}
<span class="inline-flex items-center gap-0.5 text-slate-500 dark:text-slate-400" title="Created">
<CircleDashed class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).created}</span>
</span>
{/if}
{#if getContainerStateCounts(stack).dead}
<span class="inline-flex items-center gap-0.5 text-rose-700 dark:text-rose-400" title="Dead">
<Skull class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{getContainerStateCounts(stack).dead}</span>
</span>
{/if}
{#if stack.containers.length === 0}
<span class="text-xs text-muted-foreground">-</span>
{/if}
</div>
{:else if column.id === 'cpu'}
{@const stats = getStackStats(stack)}
<div class="text-right">
{#if stats}
<span class="text-xs font-mono {stats.cpuPercent > 80 ? 'text-red-500' : stats.cpuPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}">{stats.cpuPercent.toFixed(1)}%</span>
{:else if stack.status === 'running' || stack.status === 'partial'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
{/if}
</div>
{:else if column.id === 'memory'}
{@const stats = getStackStats(stack)}
<div class="text-right">
{#if stats}
<span class="text-xs font-mono text-muted-foreground" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}</span>
{:else if stack.status === 'running' || stack.status === 'partial'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
{/if}
</div>
{:else if column.id === 'networkIO'}
{@const stats = getStackStats(stack)}
<div class="text-right whitespace-nowrap">
{#if stats}
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.networkRx)} received / ↑{formatBytes(stats.networkTx)} sent">
<span class="text-2xs text-blue-400">↓</span>{formatBytes(stats.networkRx, 0)} <span class="text-2xs text-orange-400">↑</span>{formatBytes(stats.networkTx, 0)}
</span>
{:else if stack.status === 'running' || stack.status === 'partial'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
{/if}
</div>
{:else if column.id === 'diskIO'}
{@const stats = getStackStats(stack)}
<div class="text-right whitespace-nowrap">
{#if stats}
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.blockRead)} read / ↑{formatBytes(stats.blockWrite)} written">
<span class="text-2xs text-green-400">r</span>{formatBytes(stats.blockRead, 0)} <span class="text-2xs text-yellow-400">w</span>{formatBytes(stats.blockWrite, 0)}
</span>
{:else if stack.status === 'running' || stack.status === 'partial'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
{/if}
</div>
{:else if column.id === 'networks'}
<span class="text-xs text-muted-foreground">
{getStackNetworkCount(stack) || '-'}
</span>
{:else if column.id === 'volumes'}
<span class="text-xs text-muted-foreground">
{getStackVolumeCount(stack) || '-'}
</span>
{:else if column.id === 'status'}
{@const StatusIcon = getStackStatusIcon(stack.status)}
<span class={getStatusClasses(stack.status)}>
<StatusIcon class="w-3 h-3" />
{stack.status}
</span>
{:else if column.id === 'actions'}
<div class="relative flex gap-1 justify-end">
{#if operationError?.id === stack.name && operationError?.message}
<div class="absolute bottom-full right-0 mb-1 z-50 bg-destructive text-destructive-foreground rounded-md shadow-lg p-2 text-xs flex items-start gap-2 max-w-lg w-max">
<AlertTriangle class="w-3 h-3 flex-shrink-0 mt-0.5" />
<span class="break-words">{operationError.message}</span>
<button onclick={() => operationError = null} class="flex-shrink-0 hover:bg-white/20 rounded p-0.5">
<X class="w-3 h-3" />
</button>
</div>
{/if}
{#if stack.status === 'not deployed' && source.gitStack}
<button
type="button"
onclick={() => openGitModal(source.gitStack)}
title="Edit git stack"
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-purple-500" />
</button>
<GitDeployProgressPopover
stackId={source.gitStack.id}
stackName={stack.name}
onComplete={fetchStacks}
>
{#snippet children()}
<button
type="button"
title="Deploy"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Rocket class="w-3 h-3 text-muted-foreground hover:text-violet-500" />
</button>
{/snippet}
</GitDeployProgressPopover>
{:else}
{#if source.sourceType === 'git' && source.gitStack}
<GitDeployProgressPopover
stackId={source.gitStack.id}
stackName={stack.name}
onComplete={fetchStacks}
>
{#snippet children()}
<button
type="button"
title="Sync from Git"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<RefreshCw class="w-3 h-3 text-muted-foreground hover:text-purple-500" />
</button>
{/snippet}
</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}
<button
type="button"
onclick={() => openGitModal(source.gitStack)}
title="Edit git stack"
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-purple-500" />
</button>
{/if}
{/if}
{#if stack.containers && stack.containers.length > 0}
<button
type="button"
onclick={() => viewStackLogs(stack)}
title="View logs"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<ScrollText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if stackActionLoading === stack.name}
<div class="p-1">
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
</div>
{:else if stack.status === 'running' || stack.status === 'partial'}
{#if $canAccess('stacks', 'restart')}
<ConfirmPopover
open={false}
action="Restart"
itemType="stack"
itemName={stack.name}
title="Restart"
onConfirm={() => restartStack(stack.name)}
onOpenChange={() => {}}
>
{#snippet children({ open })}
<RotateCcw class="w-3 h-3 {open ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('stacks', 'stop')}
<ConfirmPopover
open={confirmStopName === stack.name}
action="Stop"
itemType="stack"
itemName={stack.name}
title="Stop"
onConfirm={() => stopStack(stack.name)}
onOpenChange={(open) => confirmStopName = open ? stack.name : null}
>
{#snippet children({ open })}
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('stacks', 'stop')}
<ConfirmPopover
open={confirmDownName === stack.name}
action="Down"
itemType="stack"
itemName={stack.name}
title="Down (remove containers)"
onConfirm={() => downStack(stack.name)}
onOpenChange={(open) => confirmDownName = open ? stack.name : null}
>
{#snippet children({ open })}
<ArrowBigDown class="w-3 h-3 {open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
{:else}
{#if $canAccess('stacks', 'start')}
<button
type="button"
onclick={() => startStack(stack.name)}
title="Start"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{/if}
{/if}
{#if $canAccess('stacks', 'remove')}
<ConfirmPopover
open={confirmDeleteName === stack.name}
action="Delete"
itemType="stack"
itemName={stack.name}
title="Remove"
onConfirm={() => removeStack(stack.name)}
onOpenChange={(open) => confirmDeleteName = open ? stack.name : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
{/snippet}
{#snippet expandedRow(stack, rowState)}
{#if stack.containerDetails?.length > 0}
<div class="p-4 pl-12 shadow-inner bg-muted/30">
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
{#each stack.containerDetails as container (container.id)}
{@const isLoading = containerActionLoading === container.id}
<div class="p-3 rounded-lg bg-background border text-xs">
<div class="flex items-center gap-2 mb-2">
<Box class="w-4 h-4 shrink-0 {container.state === 'running' ? 'text-emerald-500' : 'text-muted-foreground'}" />
<span class="font-medium truncate flex-1" title={container.name}>{container.service}</span>
{#if container.health}
<span title={container.health}>
{#if container.health === 'healthy'}
<HeartPulse class="w-3.5 h-3.5 {getHealthClasses(container.health)}" />
{:else if container.health === 'unhealthy'}
<HeartOff class="w-3.5 h-3.5 {getHealthClasses(container.health)}" />
{:else}
<Heart class="w-3.5 h-3.5 {getHealthClasses(container.health)}" />
{/if}
</span>
{/if}
<span class={getStatusClasses(container.state)}>{container.state}</span>
</div>
<div class="text-muted-foreground mb-2 space-y-0.5">
<div class="truncate" title={container.image}>{container.image}</div>
<div class="flex items-center gap-2 text-2xs">
<span class="inline-flex items-center gap-1">
<Clock class="w-2.5 h-2.5" />
{formatUptime(container.status)}
</span>
{#if container.restartCount > 0}
<span class="inline-flex items-center gap-0.5 text-amber-600 dark:text-amber-400" title="{container.restartCount} restart{container.restartCount > 1 ? 's' : ''}">
<RotateCw class="w-2.5 h-2.5" />
{container.restartCount}
</span>
{/if}
</div>
</div>
<!-- CPU/Memory/Net/Disk mini sparkline graphs -->
{#if container.state === 'running'}
{@const stats = containerStats.get(container.id)}
{@const history = containerStatsHistory.get(container.id)}
{#key statsUpdateCount}
<div class="grid grid-cols-4 gap-1.5 mb-2">
<!-- CPU sparkline -->
<div class="space-y-0">
<div class="flex justify-between text-2xs">
<span class="text-muted-foreground">CPU</span>
<span class="font-mono {stats?.cpuPercent && stats.cpuPercent > 80 ? 'text-red-500' : stats?.cpuPercent && stats.cpuPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}">{stats?.cpuPercent?.toFixed(0) ?? '-'}%</span>
</div>
{#if history?.cpu && history.cpu.length >= 2}
<svg class="w-full h-4" viewBox="0 0 60 16" preserveAspectRatio="none">
<path d={generateAreaPath(history.cpu, 60, 16)} fill="rgba(59, 130, 246, 0.15)" />
<path d={generateSparklinePath(history.cpu, 60, 16)} fill="none" stroke="rgb(59, 130, 246)" stroke-width="1" />
</svg>
{:else}
<div class="h-4 bg-muted/30 rounded animate-pulse"></div>
{/if}
</div>
<!-- Memory sparkline -->
<div class="space-y-0">
<div class="flex justify-between text-2xs">
<span class="text-muted-foreground">Mem</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytes(stats.memoryUsage) : '-'}</span>
</div>
{#if history?.mem && history.mem.length >= 2}
<svg class="w-full h-4" viewBox="0 0 60 16" preserveAspectRatio="none">
<path d={generateAreaPath(history.mem, 60, 16)} fill="rgba(168, 85, 247, 0.15)" />
<path d={generateSparklinePath(history.mem, 60, 16)} fill="none" stroke="rgb(168, 85, 247)" stroke-width="1" />
</svg>
{:else}
<div class="h-4 bg-muted/30 rounded animate-pulse"></div>
{/if}
</div>
<!-- Network I/O sparkline -->
<div class="space-y-0">
<div class="flex justify-between text-2xs">
<span class="text-muted-foreground">Net</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytes(stats.networkRx + stats.networkTx) : '-'}</span>
</div>
{#if history?.netRx && history.netRx.length >= 2}
<svg class="w-full h-4" viewBox="0 0 60 16" preserveAspectRatio="none">
<path d={generateAreaPath(history.netRx.map((rx, i) => rx + (history.netTx[i] || 0)), 60, 16)} fill="rgba(34, 197, 94, 0.15)" />
<path d={generateSparklinePath(history.netRx.map((rx, i) => rx + (history.netTx[i] || 0)), 60, 16)} fill="none" stroke="rgb(34, 197, 94)" stroke-width="1" />
</svg>
{:else}
<div class="h-4 bg-muted/30 rounded animate-pulse"></div>
{/if}
</div>
<!-- Disk I/O sparkline -->
<div class="space-y-0">
<div class="flex justify-between text-2xs">
<span class="text-muted-foreground">Disk</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytes(stats.blockRead + stats.blockWrite) : '-'}</span>
</div>
{#if history?.diskR && history.diskR.length >= 2}
<svg class="w-full h-4" viewBox="0 0 60 16" preserveAspectRatio="none">
<path d={generateAreaPath(history.diskR.map((r, i) => r + (history.diskW[i] || 0)), 60, 16)} fill="rgba(251, 146, 60, 0.15)" />
<path d={generateSparklinePath(history.diskR.map((r, i) => r + (history.diskW[i] || 0)), 60, 16)} fill="none" stroke="rgb(251, 146, 60)" stroke-width="1" />
</svg>
{:else}
<div class="h-4 bg-muted/30 rounded animate-pulse"></div>
{/if}
</div>
</div>
{/key}
{/if}
<div class="flex flex-wrap gap-1.5 mb-2 text-2xs">
<!-- Clickable ports (dedupe by publicPort for IPv4/IPv6) -->
{#if container.ports.length > 0}
{@const uniquePorts = container.ports.filter((p, i, arr) => p.publicPort && arr.findIndex(x => x.publicPort === p.publicPort) === i)}
{#each uniquePorts.slice(0, 2) as port}
{@const url = getPortUrl(port.publicPort)}
{#if url}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
title="Open {url} in new tab"
>
<code>:{port.publicPort}</code>
<ExternalLink class="w-2.5 h-2.5" />
</a>
{:else}
<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<code>:{port.publicPort}</code>
</span>
{/if}
{/each}
{#if uniquePorts.length > 2}
<span class="text-muted-foreground">+{uniquePorts.length - 2}</span>
{/if}
{/if}
<!-- Network with IP -->
{#if container.networks.length > 0}
{@const ip = getContainerIp(container.networks)}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<Network class="w-2.5 h-2.5" />
{ip !== '-' ? ip : container.networks.length}
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap max-w-none">
{#each container.networks as net}
<div class="font-mono text-xs">{net.name}: {net.ipAddress || 'no IP'}</div>
{/each}
</Tooltip.Content>
</Tooltip.Root>
{/if}
<!-- Volumes -->
{#if container.volumeCount > 0}
<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200" title="{container.volumeCount} volume{container.volumeCount > 1 ? 's' : ''} mounted">
<HardDrive class="w-2.5 h-2.5" />
{container.volumeCount}
</span>
{/if}
</div>
<div class="flex items-center justify-between pt-2 border-t border-muted">
<div class="flex gap-1">
<button
type="button"
title="View logs"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/logs?container=${container.id}`, envId)); }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<ScrollText class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{#if container.state === 'running' && $canAccess('containers', 'exec')}
<button
type="button"
title="Open terminal"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/terminal?container=${container.id}`, envId)); }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Terminal class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if container.state === 'running' && $canAccess('containers', 'files')}
<button
type="button"
title="Browse files"
onclick={(e) => { e.stopPropagation(); browseFiles(container.id, container.name); }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FolderOpen class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
<button
type="button"
title="Inspect container"
onclick={(e) => { e.stopPropagation(); inspectContainer(container.id, container.name); }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Eye class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
</div>
<div class="relative flex gap-1">
{#if operationError?.id === container.id && operationError?.message}
<div class="absolute bottom-full right-0 mb-1 z-50 bg-destructive text-destructive-foreground rounded-md shadow-lg p-2 text-xs flex items-start gap-2 max-w-lg w-max">
<AlertTriangle class="w-3 h-3 flex-shrink-0 mt-0.5" />
<span class="break-words">{operationError.message}</span>
<button onclick={() => operationError = null} class="flex-shrink-0 hover:bg-white/20 rounded p-0.5">
<X class="w-3 h-3" />
</button>
</div>
{/if}
{#if isLoading}
<Loader2 class="w-3.5 h-3.5 animate-spin text-muted-foreground" />
{:else}
{#if container.state === 'running'}
{#if $canAccess('containers', 'restart')}
<ConfirmPopover
open={confirmRestartContainerId === container.id}
action="Restart"
itemType="container"
itemName={container.service}
title="Restart"
onConfirm={() => restartContainer(container.id)}
onOpenChange={(open) => confirmRestartContainerId = open ? container.id : null}
>
{#snippet children({ open })}
<RotateCcw class="w-3.5 h-3.5 {open ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('containers', 'pause')}
<ConfirmPopover
open={confirmPauseContainerId === container.id}
action="Pause"
itemType="container"
itemName={container.service}
title="Pause"
onConfirm={() => pauseContainer(container.id)}
onOpenChange={(open) => confirmPauseContainerId = open ? container.id : null}
>
{#snippet children({ open })}
<Pause class="w-3.5 h-3.5 {open ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('containers', 'stop')}
<ConfirmPopover
open={confirmStopContainerId === container.id}
action="Stop"
itemType="container"
itemName={container.service}
title="Stop"
onConfirm={() => stopContainer(container.id)}
onOpenChange={(open) => confirmStopContainerId = open ? container.id : null}
>
{#snippet children({ open })}
<Square class="w-3.5 h-3.5 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
{:else if container.state === 'paused'}
{#if $canAccess('containers', 'unpause')}
<button
type="button"
title="Unpause"
onclick={(e) => unpauseContainer(container.id, e)}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
</button>
{/if}
{:else}
{#if $canAccess('containers', 'start')}
<button
type="button"
title="Start"
onclick={(e) => startContainer(container.id, e)}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
</button>
{/if}
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/snippet}
</DataGrid>
{/if}
</div>
<!-- Create Stack Modal -->
<StackModal
bind:open={showCreateModal}
mode="create"
onClose={() => showCreateModal = false}
onSuccess={fetchStacks}
/>
<!-- Edit Stack Modal -->
<StackModal
bind:open={showEditModal}
mode="edit"
stackName={editingStackName}
onClose={() => {
showEditModal = false;
editingStackName = '';
}}
onSuccess={fetchStacks}
/>
<GitStackModal
bind:open={showGitModal}
gitStack={editingGitStack}
environmentId={envId}
repositories={gitRepositories}
credentials={gitCredentials}
onClose={() => {
showGitModal = false;
editingGitStack = null;
}}
onSaved={fetchStacks}
/>
<ContainerInspectModal
bind:open={showInspectModal}
containerId={inspectContainerId}
containerName={inspectContainerName}
/>
<FileBrowserModal
bind:open={showFileBrowserModal}
containerId={fileBrowserContainerId}
containerName={fileBrowserContainerName}
envId={envId ?? undefined}
onclose={() => showFileBrowserModal = false}
/>
<BatchOperationModal
bind:open={showBatchOpModal}
title={batchOpTitle}
operation={batchOpOperation}
entityType="stacks"
items={batchOpItems}
envId={envId ?? undefined}
options={{ force: true }}
onClose={() => showBatchOpModal = false}
onComplete={handleBatchComplete}
/>