mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 05:40:11 +00:00
Initial commit
This commit is contained in:
265
routes/stacks/GitDeployProgressPopover.svelte
Normal file
265
routes/stacks/GitDeployProgressPopover.svelte
Normal file
@@ -0,0 +1,265 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user