v1.0.19
This commit is contained in:
1 parent
4b430340db
commit
9c451aedf9
120 files changed
+7777
-6046
No files matched your search
+290
-80
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user