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

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>