Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 93 additions & 16 deletions dash/src/components/deployments/deployment-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { DeploymentMonitor } from "@/components/deployments"
import type { Deployment, App } from "@/types"
import { Loader2, Clock, CheckCircle2, XCircle, PlayCircle, AlertCircle } from "lucide-react"
import { Loader2, Clock, CheckCircle2, XCircle, PlayCircle, AlertCircle, Square, SquareSlash } from "lucide-react"
import { deploymentsService } from "@/services"
import { cn } from "@/lib/utils"

export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) => {
const [deployments, setDeployments] = useState<Deployment[]>([])
const [loading, setLoading] = useState(true)
const [deploying, setDeploying] = useState(false)
const [stoppingIds, setStoppingIds] = useState<Set<number>>(new Set())
const [selectedDeployment, setSelectedDeployment] = useState<number | null>(null)

const fetchDeployments = async () => {
Expand Down Expand Up @@ -51,6 +53,25 @@ export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) =>
fetchDeployments()
}

const handleStop = async (deploymentId: number) => {
if (stoppingIds.has(deploymentId)) return
try {
setStoppingIds(prev => new Set(prev).add(deploymentId))
await deploymentsService.stopDeployment(deploymentId)
toast.success('Deployment stopped')
fetchDeployments()
} catch (error) {
console.error('Stop deployment error:', error)
toast.error(error instanceof Error ? error.message : 'Failed to stop deployment')
} finally {
setStoppingIds(prev => {
const next = new Set(prev)
next.delete(deploymentId)
return next
})
}
}

useEffect(() => {
fetchDeployments()

Expand All @@ -59,14 +80,14 @@ export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) =>
return () => clearInterval(interval)
}, [appId])

// Helper to get status icon and color
// Helper to get status badge with styles
const getStatusBadge = (deployment: Deployment) => {
const { status, stage } = deployment

switch (status) {
case 'success':
return (
<Badge className="bg-green-500 text-white flex items-center gap-1.5">
<Badge className="bg-green-500 text-white flex items-center gap-1.5 border-0">
<CheckCircle2 className="h-3 w-3" />
Success
</Badge>
Expand All @@ -82,23 +103,49 @@ export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) =>
case 'deploying':
case 'cloning':
return (
<Badge className="bg-blue-500 text-white flex items-center gap-1.5 animate-pulse">
<Badge className="bg-blue-500 text-white flex items-center gap-1.5 animate-pulse border-0">
<Loader2 className="h-3 w-3 animate-spin" />
{stage.charAt(0).toUpperCase() + stage.slice(1)}
</Badge>
)
case 'pending':
return (
<Badge variant="outline" className="flex items-center gap-1.5">
<Badge variant="outline" className="flex items-center gap-1.5 border-slate-300 text-slate-600">
<AlertCircle className="h-3 w-3" />
Pending
</Badge>
)
case 'stopped':
return (
<Badge className="bg-slate-400 text-white flex items-center gap-1.5 border-0">
<SquareSlash className="h-3 w-3" />
Stopped
</Badge>
)
default:
return <Badge variant="outline">{status}</Badge>
}
}

// Check if deployment can be stopped
const canStopDeployment = (deployment: Deployment) => {
return ['pending', 'building', 'deploying', 'cloning'].includes(deployment.status) && deployment.status !== 'stopped'
}

// Get border class based on status
const getDeploymentBorderClass = (status: string) => {
switch (status) {
case 'success':
return 'border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-950/20'
case 'failed':
return 'border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-950/20'
case 'stopped':
return 'border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/20'
default:
return 'border-border bg-muted/20 hover:bg-muted/30'
}
}

return (
<>
{/* Deployment Monitor */}
Expand Down Expand Up @@ -159,7 +206,10 @@ export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) =>
{deployments.map((d) => (
<div
key={d.id}
className="flex flex-col sm:flex-row sm:items-start sm:justify-between bg-muted/20 p-4 rounded-lg border hover:bg-muted/30 transition-colors gap-4"
className={cn(
'flex flex-col sm:flex-row sm:items-start sm:justify-between p-4 rounded-lg border transition-colors gap-4',
getDeploymentBorderClass(d.status)
)}
>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
Expand All @@ -170,7 +220,7 @@ export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) =>
</span>

{/* Progress indicator for in-progress deployments */}
{d.status !== 'success' && d.status !== 'failed' && d.progress > 0 && (
{d.status !== 'success' && d.status !== 'failed' && d.status !== 'stopped' && d.progress > 0 && (
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="w-24 bg-muted rounded-full h-1.5 overflow-hidden">
<div
Expand Down Expand Up @@ -204,12 +254,21 @@ export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) =>
</p>
)}

{d.error_message && (
{/* Show error message for failed deployments */}
{d.error_message && d.status === 'failed' && (
<p className="text-xs text-red-500 flex items-start gap-1">
<XCircle className="h-3 w-3 mt-0.5 shrink-0" />
<span className="break-all">{d.error_message}</span>
</p>
)}

{/* Show stopped message for stopped deployments */}
{d.status === 'stopped' && (
<p className="text-xs text-slate-500 flex items-start gap-1">
<SquareSlash className="h-3 w-3 mt-0.5 shrink-0" />
<span className="break-all">Deployment was stopped by user</span>
</p>
)}
</div>

<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-xs text-muted-foreground">
Expand All @@ -226,14 +285,32 @@ export const DeploymentsTab = ({ appId, app }: { appId: number; app?: App }) =>
</div>
</div>

<Button
variant="outline"
size="sm"
onClick={() => setSelectedDeployment(d.id)}
className="w-full sm:w-auto sm:ml-4"
>
View Logs
</Button>
<div className="flex gap-2">
{canStopDeployment(d) && (
<Button
variant="destructive"
size="sm"
onClick={() => handleStop(d.id)}
disabled={stoppingIds.has(d.id)}
className="flex items-center gap-1.5"
>
{stoppingIds.has(d.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Square className="h-3.5 w-3.5" />
)}
Stop
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setSelectedDeployment(d.id)}
className="flex items-center gap-1.5"
>
View Logs
</Button>
</div>
</div>
))}
</div>
Expand Down
80 changes: 73 additions & 7 deletions dash/src/components/deployments/deployment-monitor.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { Terminal, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Terminal, CheckCircle2, XCircle, AlertCircle, Loader2, Square, CircleSlash, SquareSlash } from 'lucide-react';
import { useDeploymentMonitor } from '@/hooks';
import { deploymentsService } from '@/services';
import { LogLine } from '@/components/logs/log-line';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
Expand All @@ -23,6 +25,7 @@ export const DeploymentMonitor = ({ deploymentId, open, onClose, onComplete }: P
const bottomRef = useRef<HTMLDivElement>(null);

const completedRef = useRef(false);
const [stopping, setStopping] = useState(false);

const { logs, status, error, isConnected, isLoading, isLive, reset } = useDeploymentMonitor({
deploymentId,
Expand Down Expand Up @@ -52,6 +55,20 @@ export const DeploymentMonitor = ({ deploymentId, open, onClose, onComplete }: P
onClose();
};

const handleStop = async () => {
if (stopping) return;
try {
setStopping(true);
await deploymentsService.stopDeployment(deploymentId);
toast.success('Deployment stopped');
} catch (err) {
console.error('Stop deployment error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to stop deployment');
} finally {
setStopping(false);
}
};

const getStatusInfo = () => {
const statusValue = status?.status || 'pending';

Expand Down Expand Up @@ -82,15 +99,31 @@ export const DeploymentMonitor = ({ deploymentId, open, onClose, onComplete }: P
icon: <AlertCircle className="h-4 w-4" />,
label: 'Pending',
};
case 'stopped':
return {
color: 'bg-slate-500 text-white',
icon: <SquareSlash className="h-4 w-4" />,
label: 'Stopped',
};
default:
return {
color: 'bg-gray-500 text-white',
color: 'bg-slate-500 text-white',
icon: null,
label: statusValue,
};
}
};

const canStop = () => {
const statusValue = status?.status || 'pending';
return ['pending', 'building', 'deploying', 'cloning'].includes(statusValue) && statusValue !== 'stopped';
};

const isTerminalStatus = () => {
const statusValue = status?.status || '';
return ['success', 'failed', 'stopped'].includes(statusValue);
};

const statusInfo = getStatusInfo();

return (
Expand Down Expand Up @@ -127,6 +160,24 @@ export const DeploymentMonitor = ({ deploymentId, open, onClose, onComplete }: P
<Badge variant="outline" className="font-mono text-xs px-2 py-0.5">
#{deploymentId}
</Badge>

{/* Stop Button */}
{canStop() && (
<Button
variant="destructive"
size="sm"
onClick={handleStop}
disabled={stopping}
className="flex items-center gap-1.5"
>
{stopping ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Square className="h-3.5 w-3.5" />
)}
Stop
</Button>
)}
</div>
</SheetHeader>

Expand All @@ -143,8 +194,8 @@ export const DeploymentMonitor = ({ deploymentId, open, onClose, onComplete }: P
</span>
</div>

{/* Progress Bar */}
{status.status !== 'success' && status.status !== 'failed' && (
{/* Progress Bar - Hide for terminal statuses */}
{!isTerminalStatus() && (
<div className="flex items-center gap-3 min-w-[200px]">
<div className="flex-1 bg-muted rounded-full h-2 overflow-hidden">
<div
Expand All @@ -160,8 +211,23 @@ export const DeploymentMonitor = ({ deploymentId, open, onClose, onComplete }: P
</div>
)}

{/* Error Banner - Only show for live deployments */}
{error && isLive && (
{/* Stopped Banner */}
{status?.status === 'stopped' && (
<div className="px-6 py-3 bg-slate-500/10 border-b border-slate-500/30 flex items-center gap-3 shrink-0">
<CircleSlash className="h-5 w-5 text-slate-500" />
<span className="font-semibold text-slate-500">
Deployment Stopped
</span>
{status.duration && (
<span className="text-sm text-slate-500/80">
Stopped at {status.progress}% after {status.duration}s
</span>
)}
</div>
)}

{/* Error Banner - Only show for live deployments with failed status */}
{error && isLive && status?.status === 'failed' && (
<div className="px-6 py-3 bg-red-500/10 border-b border-red-500/30 flex items-start gap-3 shrink-0">
<XCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1">
Expand Down
15 changes: 13 additions & 2 deletions dash/src/hooks/use-deployment-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,17 @@ export const useDeploymentMonitor = ({
progress: deployment.progress,
message: deployment.status === 'success'
? 'Deployment completed successfully'
: deployment.status === 'stopped'
? 'Deployment was stopped by user'
: 'Deployment failed',
error_message: deployment.error_message,
duration: deployment.duration,
});

if (deployment.status === 'failed' && deployment.error_message) {
setError(deployment.error_message);
} else if (deployment.status === 'stopped') {
setError('Deployment was stopped by user');
}

setIsLoading(false);
Expand Down Expand Up @@ -302,6 +306,13 @@ export const useDeploymentMonitor = ({
setError(statusData.error_message);
onError?.(statusData.error_message);
}

// Handle stopped status
if (statusData.status === 'stopped' && !hasCompletedRef.current) {
console.log('[DeploymentMonitor] Deployment stopped by user');
hasCompletedRef.current = true;
setError('Deployment was stopped by user');
}
break;
}

Expand Down Expand Up @@ -335,8 +346,8 @@ export const useDeploymentMonitor = ({

// Check the current status from state to decide on reconnection
setStatus((currentStatus) => {
// Don't reconnect if deployment is complete
if (currentStatus?.status === 'success' || currentStatus?.status === 'failed') {
// Don't reconnect if deployment is complete (success, failed, or stopped)
if (currentStatus?.status === 'success' || currentStatus?.status === 'failed' || currentStatus?.status === 'stopped') {
console.log('[DeploymentMonitor] Deployment complete, not reconnecting');
return currentStatus;
}
Expand Down
Loading
Loading