mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
266 lines
7.1 KiB
Svelte
266 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
import * as Popover from '$lib/components/ui/popover';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import {
|
|
Rocket,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
AlertCircle,
|
|
GitBranch,
|
|
FileCode,
|
|
Server,
|
|
Link
|
|
} from 'lucide-svelte';
|
|
import type { Snippet } from 'svelte';
|
|
import { Progress } from '$lib/components/ui/progress';
|
|
|
|
interface Props {
|
|
stackId: number;
|
|
stackName: string;
|
|
onComplete?: () => void;
|
|
children: Snippet;
|
|
}
|
|
|
|
let { stackId, stackName, onComplete, children }: Props = $props();
|
|
|
|
interface StepProgress {
|
|
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error';
|
|
message?: string;
|
|
step?: number;
|
|
totalSteps?: number;
|
|
error?: string;
|
|
}
|
|
|
|
let open = $state(false);
|
|
let overallStatus = $state<'idle' | 'deploying' | 'complete' | 'error'>('idle');
|
|
let currentStep = $state<StepProgress | null>(null);
|
|
let steps = $state<StepProgress[]>([]);
|
|
let errorMessage = $state('');
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
return 'text-muted-foreground';
|
|
}
|
|
|
|
async function startDeploy() {
|
|
steps = [];
|
|
currentStep = null;
|
|
overallStatus = 'deploying';
|
|
errorMessage = '';
|
|
open = true;
|
|
|
|
try {
|
|
const response = await fetch(`/api/git/stacks/${stackId}/deploy-stream`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || 'Failed to start deployment');
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
throw new Error('No response body');
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Failed to deploy git stack:', error);
|
|
overallStatus = 'error';
|
|
errorMessage = error.message || 'Failed to deploy';
|
|
}
|
|
}
|
|
|
|
function handleOpenChange(isOpen: boolean) {
|
|
// Only allow closing via the Close button (not by clicking outside)
|
|
// When deploying, complete, or error - require explicit close
|
|
if (!isOpen && overallStatus !== 'idle') {
|
|
return;
|
|
}
|
|
|
|
// Start deploy when opening
|
|
if (isOpen && !open) {
|
|
startDeploy();
|
|
return;
|
|
}
|
|
|
|
open = isOpen;
|
|
}
|
|
|
|
function handleClose() {
|
|
open = false;
|
|
// Reset state when closed
|
|
overallStatus = 'idle';
|
|
steps = [];
|
|
currentStep = null;
|
|
errorMessage = '';
|
|
}
|
|
|
|
const progressPercentage = $derived(
|
|
currentStep?.step && currentStep?.totalSteps
|
|
? Math.round((currentStep.step / currentStep.totalSteps) * 100)
|
|
: 0
|
|
);
|
|
</script>
|
|
|
|
<Popover.Root {open} onOpenChange={handleOpenChange}>
|
|
<Popover.Trigger asChild>
|
|
{@render children()}
|
|
</Popover.Trigger>
|
|
<Popover.Content
|
|
class="w-80 p-0 overflow-hidden flex flex-col"
|
|
align="end"
|
|
sideOffset={8}
|
|
interactOutsideBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
|
|
escapeKeydownBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
|
|
>
|
|
<!-- 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>
|
|
</div>
|
|
|
|
<!-- 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" />
|
|
<span class="break-all">{errorMessage}</span>
|
|
</div>
|
|
{/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
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</Popover.Content>
|
|
</Popover.Root>
|