This commit is contained in:
jarek committed 2026-03-02 07:59:58 +01:00
1 parent 4b430340db
commit 9c451aedf9
120 files changed
+7777 -6046

No files matched your search

+290 -80
View File
@@ -12,7 +12,7 @@
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, 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 { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
@@ -22,9 +22,11 @@
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
import LogsPanel from '../logs/LogsPanel.svelte';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
import { canAccess } from '$lib/stores/auth';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
@@ -128,33 +130,74 @@
}
// Fetch container stats
let statsAbortController: AbortController | null = null;
async function fetchStats() {
// Abort any previous in-flight stream
statsAbortController?.abort();
statsAbortController = new AbortController();
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);
const response = await fetch(
appendEnvParam('/api/containers/stats/stream', envId),
{ signal: statsAbortController.signal }
);
for (const stat of stats) {
if (!stat.id) continue;
statsMap.set(stat.id, stat);
if (!response.ok || !response.body) return;
// 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);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let currentEvent = '';
for (const line of lines) {
if (line.startsWith(':')) continue;
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
if (currentEvent === 'stat') {
try {
const stat: ContainerStats = JSON.parse(line.slice(6));
if (!stat.id) continue;
// Merge into existing stats map
containerStats = new Map(containerStats).set(stat.id, stat);
// Append to history
const newHistory = new Map(containerStatsHistory);
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);
containerStatsHistory = newHistory;
statsUpdateCount++;
} catch {
// Skip malformed data
}
}
currentEvent = '';
}
}
}
} catch (error: any) {
if (error?.name !== 'AbortError') {
console.error('Failed to fetch container stats:', error);
}
containerStats = statsMap;
containerStatsHistory = newHistory;
statsUpdateCount++; // Trigger reactivity for expanded rows
} catch (error) {
console.error('Failed to fetch container stats:', error);
}
}
@@ -229,9 +272,10 @@
return { cpuPercent, memoryUsage, memoryLimit, networkRx, networkTx, blockRead, blockWrite, runningCount };
}
// Search and sort state
let searchInput = $state('');
let searchQuery = $state('');
// Search and sort state - initialize from URL for persistence across navigation
const initialSearch = $page.url.searchParams.get('search') ?? '';
let searchInput = $state(initialSearch);
let searchQuery = $state(initialSearch);
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
@@ -281,6 +325,93 @@
saveStatusFilter();
});
// Inline logs panel state
interface ActiveLogs {
containerId: string;
containerName: string;
}
let activeLogs = $state<ActiveLogs[]>([]);
let currentLogsContainerId = $state<string | null>(null);
function hasActiveLogs(containerId: string): boolean {
return activeLogs.some(l => l.containerId === containerId);
}
function showContainerLogs(container: { id: string; name: string }) {
if (hasActiveLogs(container.id)) {
currentLogsContainerId = container.id;
} else {
activeLogs = [...activeLogs, { containerId: container.id, containerName: container.name }];
currentLogsContainerId = container.id;
}
}
function closeLogs(containerId: string) {
activeLogs = activeLogs.filter(l => l.containerId !== containerId);
if (currentLogsContainerId === containerId) {
currentLogsContainerId = null;
}
}
// Layout state - horizontal (panels below) or vertical (panels on right)
type LayoutMode = 'horizontal' | 'vertical';
const LAYOUT_STORAGE_KEY = 'dockhand-stacks-layout';
const PANEL_WIDTH_STORAGE_KEY = 'dockhand-stacks-panel-width';
const DEFAULT_PANEL_WIDTH = 400;
const MIN_PANEL_WIDTH = 250;
const MAX_PANEL_WIDTH = 800;
let layoutMode = $state<LayoutMode>('horizontal');
let panelWidth = $state(DEFAULT_PANEL_WIDTH);
let isResizingWidth = $state(false);
let mainContentRef: HTMLDivElement | undefined;
function loadLayoutMode() {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode;
if (saved === 'horizontal' || saved === 'vertical') {
layoutMode = saved;
}
const savedWidth = localStorage.getItem(PANEL_WIDTH_STORAGE_KEY);
if (savedWidth) {
const w = parseInt(savedWidth);
if (!isNaN(w) && w >= MIN_PANEL_WIDTH && w <= MAX_PANEL_WIDTH) {
panelWidth = w;
}
}
}
}
function toggleLayoutMode() {
layoutMode = layoutMode === 'horizontal' ? 'vertical' : 'horizontal';
if (typeof window !== 'undefined') {
localStorage.setItem(LAYOUT_STORAGE_KEY, layoutMode);
}
}
function startWidthResize(e: MouseEvent) {
e.preventDefault();
isResizingWidth = true;
document.addEventListener('mousemove', handleWidthResize);
document.addEventListener('mouseup', stopWidthResize);
}
function handleWidthResize(e: MouseEvent) {
if (!isResizingWidth || !mainContentRef) return;
const containerRect = mainContentRef.getBoundingClientRect();
const newWidth = containerRect.right - e.clientX;
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
}
function stopWidthResize() {
isResizingWidth = false;
document.removeEventListener('mousemove', handleWidthResize);
document.removeEventListener('mouseup', stopWidthResize);
if (typeof window !== 'undefined') {
localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(panelWidth));
}
}
// Confirmation popover state
let confirmDeleteName = $state<string | null>(null);
let confirmStopName = $state<string | null>(null);
@@ -288,6 +419,7 @@
// Stack operation loading state
let stackActionLoading = $state<string | null>(null);
let stackDownLoading = $state<string | null>(null);
// Container-level confirmation popover state
let confirmStopContainerId = $state<string | null>(null);
@@ -520,6 +652,17 @@
return () => clearTimeout(searchTimeout);
});
// Sync search query to URL for persistence across navigation
$effect(() => {
const q = searchQuery;
const url = new URL($page.url);
if (q) url.searchParams.set('search', q);
else url.searchParams.delete('search');
if (url.toString() !== $page.url.toString()) {
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true });
}
});
// Track last loaded environment to show skeleton on environment change
let lastLoadedEnvId = $state<number | null>(null);
@@ -657,6 +800,16 @@
return stackSources[stackName] || { sourceType: 'external' };
}
function getStackSystemType(stack: ComposeStackInfo): 'dockhand' | 'hawser' | null {
if (!stack.containerDetails) return null;
for (const c of stack.containerDetails) {
const img = (c.image || '').toLowerCase();
if (img.includes('fnsys/dockhand') || /(?:^|\/)dockhand(?::|$)/.test(img)) return 'dockhand';
if (img.includes('finsys/hawser') || img.includes('ghcr.io/finsys/hawser')) return 'hawser';
}
return null;
}
function getDisplayStatus(stack: ComposeStackInfo): string {
if (stack.status === 'created' && getStackSource(stack.name).sourceType === 'git') {
return 'not deployed';
@@ -687,16 +840,9 @@
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
if (!response.ok) {
const rawText = await response.text();
let errorMsg = 'Failed to start stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to start ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to start ${name}`, data.error || 'Failed to start stack');
return;
}
toast.success(`Started ${name}`);
@@ -715,16 +861,9 @@
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
if (!response.ok) {
const rawText = await response.text();
let errorMsg = 'Failed to stop stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to stop ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to stop ${name}`, data.error || 'Failed to stop stack');
return;
}
toast.success(`Stopped ${name}`);
@@ -743,16 +882,9 @@
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
if (!response.ok) {
const rawText = await response.text();
let errorMsg = 'Failed to restart stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to restart ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
return;
}
toast.success(`Restarted ${name}`);
@@ -769,22 +901,12 @@
async function downStack(name: string) {
operationError = null;
stackActionLoading = name;
stackDownLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
// Log raw response for debugging
const rawText = await response.text();
console.log(`[downStack] Response status: ${response.status}, raw body:`, rawText);
if (!response.ok) {
let errorMsg = 'Failed to bring down stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
// Response may not be valid JSON
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to bring down ${name}`, data.error || 'Failed to bring down stack');
return;
}
toast.success(`Brought down ${name}`);
@@ -795,6 +917,7 @@
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
} finally {
stackActionLoading = null;
stackDownLoading = null;
}
}
@@ -1060,13 +1183,7 @@
onMount(() => {
loadExpandedState();
loadStatusFilter();
// Check for search query param from URL (e.g., from container stack link)
const urlSearch = $page.url.searchParams.get('search');
if (urlSearch) {
searchInput = urlSearch;
searchQuery = urlSearch;
}
loadLayoutMode();
// Initial fetch is handled by $effect - no need to duplicate here
@@ -1115,6 +1232,11 @@
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
document.removeEventListener('mousemove', handleWidthResize);
document.removeEventListener('mouseup', stopWidthResize);
// Abort any in-flight stats stream
statsAbortController?.abort();
});
</script>
@@ -1161,6 +1283,19 @@
<RefreshCw class="w-3.5 h-3.5" />
Refresh
</Button>
<Button
size="sm"
variant="outline"
onclick={toggleLayoutMode}
class="h-8 w-8 p-0"
title={layoutMode === 'horizontal' ? 'Switch to vertical layout (logs on side)' : 'Switch to horizontal layout (logs below)'}
>
{#if layoutMode === 'horizontal'}
<LayoutPanelLeft class="w-4 h-4" />
{:else}
<Rows3 class="w-4 h-4" />
{/if}
</Button>
{#if $canAccess('stacks', 'create')}
<Button size="sm" variant="outline" onclick={() => openGitModal()}>
<GitBranch class="w-3.5 h-3.5" />
@@ -1300,6 +1435,11 @@
description="Create a stack or deploy from Git to get started"
/>
{:else}
<!-- Main content area - changes layout based on mode -->
<div
bind:this={mainContentRef}
class="flex-1 min-h-0 {layoutMode === 'vertical' ? 'flex gap-3' : 'flex flex-col gap-3'}"
>
<DataGrid
data={filteredStacks}
keyField="name"
@@ -1327,6 +1467,7 @@
{#snippet cell(column, stack, rowState)}
{@const source = getStackSource(stack.name)}
{#if column.id === 'name'}
{@const systemType = getStackSystemType(stack)}
{#if source.sourceType !== 'git'}
<!-- Internal stacks (including those needing file location) are clickable -->
<button
@@ -1340,6 +1481,23 @@
<!-- Git stacks open in GitStackModal instead -->
<span class="font-medium text-xs">{stack.name}</span>
{/if}
{#if systemType}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="text-2xs py-0 px-1 shrink-0 bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20 cursor-help flex items-center gap-0.5">
{#if systemType === 'dockhand'}
<Ship class="w-2.5 h-2.5" />
{:else}
<Cable class="w-2.5 h-2.5" />
{/if}
{systemType === 'dockhand' ? 'Dockhand' : 'Hawser'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-sm whitespace-nowrap">{systemType === 'dockhand' ? 'Dockhand management container' : 'Hawser remote agent'}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if stackEnvVarCounts[stack.name]}
<Tooltip.Root>
<Tooltip.Trigger>
@@ -1650,7 +1808,7 @@
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'}" />
<ArrowBigDown class="w-3 h-3 {stackDownLoading === stack.name ? 'animate-bounce text-orange-500' : open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -1834,11 +1992,19 @@
<div class="flex gap-1">
<button
type="button"
title="View logs"
title="Open logs inline"
onclick={(e) => { e.stopPropagation(); showContainerLogs(container); }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer {currentLogsContainerId === container.id ? 'bg-muted text-blue-500' : ''}"
>
<FileText class="w-3.5 h-3.5 {currentLogsContainerId === container.id ? 'text-blue-500' : 'text-muted-foreground hover:text-foreground'}" />
</button>
<button
type="button"
title="Open logs in full view"
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" />
<FileOutput class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{#if container.state === 'running' && $canAccess('containers', 'exec')}
<button
@@ -1968,6 +2134,50 @@
{/if}
{/snippet}
</DataGrid>
<!-- Panels section - in vertical mode this is a column on the right with resize handle -->
{#if layoutMode === 'vertical' && currentLogsContainerId}
{@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)}
{#if activeLog}
<!-- Vertical resize handle -->
<div
role="separator"
aria-orientation="vertical"
class="w-2 cursor-ew-resize flex items-center justify-center hover:bg-muted transition-colors {isResizingWidth ? 'bg-muted' : ''}"
onmousedown={startWidthResize}
>
<GripVertical class="w-4 h-8 text-zinc-600" />
</div>
<div class="flex flex-col gap-3 h-full overflow-hidden" style="width: {panelWidth}px; flex-shrink: 0;">
<div class="flex-1 min-h-0">
<LogsPanel
containerId={activeLog.containerId}
containerName={activeLog.containerName}
visible={true}
envId={envId}
fillHeight={true}
onClose={() => closeLogs(activeLog.containerId)}
/>
</div>
</div>
{/if}
{/if}
</div>
<!-- Panels for horizontal mode - below the table, full width -->
{#if layoutMode === 'horizontal' && currentLogsContainerId}
{@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)}
{#if activeLog}
<LogsPanel
containerId={activeLog.containerId}
containerName={activeLog.containerName}
visible={true}
envId={envId}
onClose={() => closeLogs(activeLog.containerId)}
/>
{/if}
{/if}
{/if}
</div>
+190 -190
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Progress } from '$lib/components/ui/progress';
import {
Rocket,
CheckCircle2,
@@ -12,11 +13,15 @@
FileCode,
Server,
Link,
AlertTriangle
AlertTriangle,
Copy,
Check,
Database,
KeyRound
} from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { Progress } from '$lib/components/ui/progress';
import { appSettings } from '$lib/stores/settings';
import { watchJob } from '$lib/utils/sse-fetch';
interface Props {
stackId: number;
@@ -28,7 +33,7 @@
let { stackId, stackName, onComplete, children }: Props = $props();
interface StepProgress {
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error';
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'env' | 'secrets' | 'deploying' | 'complete' | 'error';
message?: string;
step?: number;
totalSteps?: number;
@@ -40,41 +45,29 @@
let currentStep = $state<StepProgress | null>(null);
let steps = $state<StepProgress[]>([]);
let errorMessage = $state('');
let copied = $state(false);
// Get the confirmDestructive setting from the store
const confirmDestructive = $derived($appSettings.confirmDestructive);
function getStepIcon(status: string) {
switch (status) {
case 'connecting':
return Link;
case 'cloning':
return GitBranch;
case 'fetching':
return GitBranch;
case 'reading':
return FileCode;
case 'deploying':
return Server;
case 'complete':
return CheckCircle2;
case 'error':
return XCircle;
default:
return Loader2;
case 'connecting': return Link;
case 'cloning': return GitBranch;
case 'fetching': return GitBranch;
case 'reading': return FileCode;
case 'env': return Database;
case 'secrets': return KeyRound;
case 'deploying': return Server;
case 'complete': return CheckCircle2;
case 'error': return XCircle;
default: return Loader2;
}
}
function getStepColor(status: string, isCurrentStep: boolean): string {
if (status === 'complete') {
return 'text-green-600 dark:text-green-400';
}
if (status === 'error') {
return 'text-red-600 dark:text-red-400';
}
if (isCurrentStep) {
return 'text-blue-600 dark:text-blue-400';
}
if (status === 'complete') return 'text-green-600 dark:text-green-400';
if (status === 'error') return 'text-red-600 dark:text-red-400';
if (isCurrentStep) return 'text-violet-600 dark:text-violet-400';
return 'text-muted-foreground';
}
@@ -83,11 +76,11 @@
currentStep = null;
overallStatus = 'deploying';
errorMessage = '';
open = true;
try {
const response = await fetch(`/api/git/stacks/${stackId}/deploy-stream`, {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
@@ -95,46 +88,33 @@
throw new Error(data.error || 'Failed to start deployment');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const { jobId } = await response.json();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data: StepProgress = JSON.parse(line.slice(6));
if (data.status === 'complete') {
overallStatus = 'complete';
currentStep = data;
steps = [...steps, data];
onComplete?.();
} else if (data.status === 'error') {
overallStatus = 'error';
errorMessage = data.error || 'Unknown error occurred';
currentStep = data;
steps = [...steps, data];
} else {
currentStep = data;
steps = [...steps, data];
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
await watchJob(jobId, (line) => {
try {
const data = line.data as StepProgress;
if (data.status === 'complete') {
overallStatus = 'complete';
currentStep = data;
steps = [...steps, data];
onComplete?.();
} else if (data.status === 'error') {
overallStatus = 'error';
errorMessage = data.error || 'Unknown error occurred';
currentStep = data;
steps = [...steps, data];
} else {
currentStep = data;
steps = [...steps, data];
}
} catch (e) {
console.error('Failed to process job line:', e);
}
});
if (overallStatus === 'deploying') {
overallStatus = 'complete';
onComplete?.();
}
} catch (error: any) {
console.error('Failed to deploy git stack:', error);
@@ -143,25 +123,14 @@
}
}
function handleOpenChange(isOpen: boolean) {
// Only allow closing via the Close button (not by clicking outside)
// When confirming, deploying, complete, or error - require explicit close
if (!isOpen && overallStatus !== 'idle') {
return;
function handleTriggerClick() {
if (overallStatus !== 'idle') return;
if (confirmDestructive) {
overallStatus = 'confirming';
} else {
startDeploy();
}
// When opening, show confirmation first if enabled
if (isOpen && !open) {
if (confirmDestructive) {
overallStatus = 'confirming';
open = true;
} else {
startDeploy();
}
return;
}
open = isOpen;
open = true;
}
function handleConfirmDeploy() {
@@ -174,12 +143,21 @@
}
function handleClose() {
if (overallStatus === 'deploying') return;
open = false;
// Reset state when closed
overallStatus = 'idle';
steps = [];
currentStep = null;
errorMessage = '';
copied = false;
}
async function copyLogs() {
const lines = steps.map(s => s.message || s.status);
if (errorMessage) lines.push(`Error: ${errorMessage}`);
await navigator.clipboard.writeText(lines.join('\n'));
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
const progressPercentage = $derived(
@@ -187,121 +165,143 @@
? Math.round((currentStep.step / currentStep.totalSteps) * 100)
: 0
);
const isDeploying = $derived(overallStatus === 'deploying');
</script>
<Popover.Root {open} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
{@render children()}
</Popover.Trigger>
<Popover.Content
class="{overallStatus === 'confirming' ? 'w-auto' : 'w-80'} p-0 overflow-hidden flex flex-col z-[200]"
align="end"
sideOffset={8}
interactOutsideBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
escapeKeydownBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
<!-- Trigger wrapper: intercepts click to open the dialog -->
<span role="none" style="display:contents" onclick={handleTriggerClick}>
{@render children()}
</span>
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
<Dialog.Content
class="max-w-2xl flex flex-col gap-0 p-0 overflow-hidden"
showCloseButton={false}
interactOutsideBehavior={isDeploying ? 'ignore' : 'close'}
escapeKeydownBehavior={isDeploying ? 'ignore' : 'close'}
>
{#if overallStatus === 'confirming'}
<!-- Confirmation Dialog -->
<div class="p-3 space-y-3">
<div class="flex items-start gap-2">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<!-- Header -->
<div class="px-6 py-4 border-b shrink-0">
<div class="flex items-center gap-2 min-w-0">
{#if overallStatus === 'complete'}
<CheckCircle2 class="w-5 h-5 text-green-500 shrink-0" />
{:else if overallStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500 shrink-0" />
{:else if isDeploying}
<Loader2 class="w-5 h-5 text-violet-500 animate-spin shrink-0" />
{:else}
<Rocket class="w-5 h-5 text-violet-500 shrink-0" />
{/if}
<span class="text-base font-semibold">Git deploy</span>
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1 truncate">{stackName}</code>
{#if overallStatus === 'complete'}
<Badge variant="outline" class="ml-auto shrink-0 text-green-600 border-green-600/30">Complete</Badge>
{:else if overallStatus === 'error'}
<Badge variant="outline" class="ml-auto shrink-0 text-red-600 border-red-600/30">Failed</Badge>
{:else if isDeploying}
<Badge variant="secondary" class="ml-auto shrink-0 tabular-nums text-xs">
{#if currentStep?.step && currentStep?.totalSteps}
{currentStep.step}/{currentStep.totalSteps}
{:else}
Deploying...
{/if}
</Badge>
{/if}
</div>
{#if isDeploying && currentStep?.totalSteps}
<div class="mt-3">
<Progress value={progressPercentage} class="h-1.5 [&>[data-progress]]:bg-violet-600" />
</div>
{/if}
</div>
<!-- Body: steps log -->
<div class="overflow-y-auto px-4 py-3" style="max-height: 55vh; min-height: 12rem;">
{#if overallStatus === 'confirming'}
<div class="flex items-start gap-3 py-2 px-2">
<AlertTriangle class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div class="space-y-1">
<p class="text-sm font-medium">Sync from git?</p>
<p class="text-xs text-muted-foreground">
This will pull latest changes for <strong class="text-foreground">{stackName}</strong>. Containers will only restart if the configuration changed.
<p class="font-medium">Sync from git?</p>
<p class="text-sm text-muted-foreground">
This will pull the latest changes for <strong class="text-foreground">{stackName}</strong>.
Containers will only restart if the configuration changed.
</p>
</div>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" class="h-7 text-xs" onclick={handleCancelConfirm}>
Cancel
</Button>
<Button variant="default" size="sm" class="h-7 text-xs" onclick={handleConfirmDeploy}>
Sync
</Button>
{:else if steps.length === 0 && isDeploying}
<div class="flex items-center gap-3 text-muted-foreground py-2 px-2">
<Loader2 class="w-4 h-4 animate-spin shrink-0" />
<span class="text-sm">Initializing...</span>
</div>
</div>
{:else}
<!-- Header -->
<div class="p-3 border-b space-y-2">
<div class="flex items-center gap-2 text-sm font-medium">
<Rocket class="w-4 h-4 text-violet-600" />
<span class="truncate">{stackName}</span>
{:else}
<div class="space-y-0.5">
{#each steps as step, index (index)}
{@const StepIcon = getStepIcon(step.status)}
{@const isCurrentStep = index === steps.length - 1 && isDeploying}
<div class="flex items-center gap-3 py-1.5 px-2 rounded-md text-sm hover:bg-muted/40 transition-colors">
<StepIcon
class="w-4 h-4 shrink-0 {getStepColor(step.status, isCurrentStep)} {isCurrentStep && step.status !== 'complete' && step.status !== 'error' ? 'animate-spin' : ''}"
/>
<span class="{getStepColor(step.status, isCurrentStep)}">
{step.message || step.status}
</span>
</div>
{/each}
</div>
{/if}
<!-- Overall Progress -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if overallStatus === 'idle'}
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
<span class="text-sm text-muted-foreground">Initializing...</span>
{:else if overallStatus === 'deploying'}
<Loader2 class="w-4 h-4 animate-spin text-violet-600" />
<span class="text-sm">Deploying...</span>
{:else if overallStatus === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
<span class="text-sm text-green-600">Complete!</span>
{:else if overallStatus === 'error'}
<XCircle class="w-4 h-4 text-red-600" />
<span class="text-sm text-red-600">Failed</span>
{/if}
</div>
{#if currentStep?.step && currentStep?.totalSteps}
<Badge variant="secondary" class="text-xs">
{currentStep.step}/{currentStep.totalSteps}
</Badge>
{/if}
</div>
{#if currentStep?.message && overallStatus === 'deploying'}
<p class="text-xs text-muted-foreground truncate">{currentStep.message}</p>
{/if}
{#if currentStep?.totalSteps}
<Progress value={progressPercentage} class="h-1.5 [&>[data-progress]]:bg-violet-600" />
{/if}
{#if errorMessage}
<div class="flex items-start gap-2 text-xs text-red-600 dark:text-red-400">
<AlertCircle class="w-3 h-3 shrink-0 mt-0.5" />
{#if errorMessage}
<div class="mt-3 mx-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<div class="flex items-start gap-2 text-sm text-destructive">
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
<span class="break-all">{errorMessage}</span>
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-2">
<!-- Left: copy / cancel -->
<div>
{#if overallStatus === 'confirming'}
<Button variant="outline" onclick={handleCancelConfirm}>Cancel</Button>
{:else if steps.length > 0}
<Button variant="outline" size="sm" onclick={copyLogs} class="gap-1.5">
{#if copied}
<Check class="w-3.5 h-3.5" />
Copied!
{:else}
<Copy class="w-3.5 h-3.5" />
Copy logs
{/if}
</Button>
{/if}
</div>
<!-- Steps List -->
{#if steps.length > 0}
<div class="p-2 max-h-48 overflow-auto">
<div class="space-y-1">
{#each steps as step, index (index)}
{@const StepIcon = getStepIcon(step.status)}
{@const isCurrentStep = index === steps.length - 1 && overallStatus === 'deploying'}
<div class="flex items-center gap-2 py-1 px-1 rounded text-xs hover:bg-muted/50">
<StepIcon
class="w-3.5 h-3.5 shrink-0 {getStepColor(step.status, isCurrentStep)} {isCurrentStep && step.status !== 'complete' && step.status !== 'error' ? 'animate-spin' : ''}"
/>
<span class="flex-1 {getStepColor(step.status, isCurrentStep)} truncate">
{step.message || step.status}
</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Footer -->
{#if overallStatus === 'complete' || overallStatus === 'error'}
<div class="p-2 border-t">
<Button
variant="outline"
size="sm"
class="w-full"
onclick={handleClose}
>
Close
<!-- Right: confirm / close -->
<div class="flex gap-2">
{#if overallStatus === 'confirming'}
<Button onclick={handleConfirmDeploy}>
<Rocket class="w-4 h-4" />
Deploy
</Button>
</div>
{/if}
{/if}
</Popover.Content>
</Popover.Root>
{:else}
<Button
variant={overallStatus === 'complete' ? 'default' : 'secondary'}
onclick={handleClose}
disabled={isDeploying}
>
{#if isDeploying}
<Loader2 class="w-4 h-4 animate-spin" />
Deploying...
{:else}
Close
{/if}
</Button>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
+2 -1
View File
@@ -14,6 +14,7 @@
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
// Get sidebar state to adjust modal positioning
@@ -465,7 +466,7 @@
body: JSON.stringify(body)
});
const data = await response.json();
const data = await readJobResponse(response);
if (!response.ok) {
formError = data.error || 'Failed to save git stack';
+6 -1
View File
@@ -150,9 +150,14 @@
}
scanResults = discovered;
const skippedCount = (data.skipped || []).length;
if (discovered.length === 0) {
toast.info('No compose stacks found in this directory');
if (skippedCount > 0) {
toast.info(`All ${skippedCount} stack(s) in this directory are already adopted`);
} else {
toast.info('No compose stacks found in this directory');
}
} else {
const selections = new Map<string, boolean>();
for (const stack of discovered) {
+35 -5
View File
@@ -20,6 +20,8 @@
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Alert from '$lib/components/ui/alert';
import { ErrorDialog } from '$lib/components/ui/error-dialog';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { toast } from 'svelte-sonner';
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
@@ -37,7 +39,11 @@
onSuccess: () => void; // Called after create or save
}
let { open = $bindable(), mode, stackName = '', onClose, onSuccess }: Props = $props();
let { open = $bindable(), mode: propMode, stackName: propStackName = '', onClose, onSuccess }: Props = $props();
// Local effective state - can transition from create → edit after failed deploy
let mode = $state(propMode);
let stackName = $state(propStackName);
// Form state
let newStackName = $state('');
@@ -930,11 +936,17 @@ services:
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const data = await response.json();
// When start=true, response is a job or JSON; when start=false, it's plain JSON
const data = start ? await readJobResponse(response) : await response.json();
if (!response.ok && !data.success) {
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to create stack');
}
if (data.success === false) {
throw new Error(data.error || 'Failed to create stack');
}
toast.success(`Created stack "${newStackName.trim()}"`);
onSuccess();
handleClose();
} catch (e: any) {
@@ -943,6 +955,13 @@ services:
message: e.message || 'An error occurred while creating the stack',
details: e.details
};
// If start=true, files were saved and stack is in DB — transition to edit mode
// so the user can fix and redeploy without leaving the modal
if (start) {
mode = 'edit';
stackName = newStackName.trim();
onSuccess(); // refresh stack list so the new stack appears
}
} finally {
saving = false;
}
@@ -1094,13 +1113,18 @@ services:
}
);
const data = await response.json();
// When restart=true, response is a job or JSON; when restart=false, it's plain JSON
const data = restart ? await readJobResponse(response) : await response.json();
if (!response.ok) {
if (!response.ok && !data.success) {
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to save compose file');
}
if (data.success === false) {
throw new Error(data.error || 'Failed to save compose file');
}
isDirty = false; // Reset dirty flag after successful save
toast.success(restart ? 'Stack applied' : 'Stack saved');
onSuccess();
if (!restart) {
@@ -1147,6 +1171,9 @@ services:
clearTimeout(validateTimer);
validateTimer = null;
}
// Reset mode back to prop values
mode = propMode;
stackName = propStackName;
// Reset all state
newStackName = '';
error = null;
@@ -1196,6 +1223,9 @@ services:
$effect(() => {
if (open && !hasInitialized) {
hasInitialized = true;
// Reset mode to prop values on each open
mode = propMode;
stackName = propStackName;
if (mode === 'edit' && stackName) {
loadComposeFile().then(() => {
// Auto-validate after loading