2026-02-06 23:01:30 -05:00

456 lines
14 KiB
TypeScript

'use client';
import React, { useState, useCallback, useRef } from 'react';
import {
Cloud,
Package,
Container,
Download,
Rocket,
ArrowLeft,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type {
DeployTarget,
DeployResult,
} from '@mcpengine/ai-pipeline/types';
import {
DeployStepIndicator,
type StepState,
} from './DeployStepIndicator';
import { DeployLogViewer, type LogEntry } from './DeployLogViewer';
import { DeploySuccess } from './DeploySuccess';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type DeployPhase = 'select' | 'deploying' | 'success' | 'error';
type DeployStep = 'build' | 'test' | 'package' | 'deploy' | 'verify';
interface StepConfig {
key: DeployStep;
label: string;
}
const STEPS: StepConfig[] = [
{ key: 'build', label: 'Build' },
{ key: 'test', label: 'Test' },
{ key: 'package', label: 'Package' },
{ key: 'deploy', label: 'Deploy' },
{ key: 'verify', label: 'Verify' },
];
interface TargetOption {
id: DeployTarget;
label: string;
description: string;
icon: React.ReactNode;
available: boolean;
badge?: string;
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface DeployPageProps {
projectId: string;
projectName?: string;
projectSlug?: string;
className?: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DeployPage({
projectId,
projectName = 'MCP Server',
projectSlug,
className,
}: DeployPageProps) {
const [phase, setPhase] = useState<DeployPhase>('select');
const [selectedTarget, setSelectedTarget] = useState<DeployTarget | null>(null);
const [stepStates, setStepStates] = useState<Record<DeployStep, StepState>>({
build: 'waiting',
test: 'waiting',
package: 'waiting',
deploy: 'waiting',
verify: 'waiting',
});
const [logs, setLogs] = useState<LogEntry[]>([]);
const [result, setResult] = useState<DeployResult | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
// ── Target options ─────────────────────────────────────────────────────
const targets: TargetOption[] = [
{
id: 'mcpengine',
label: 'MCPEngine',
description: 'Deploy to MCPEngine cloud. Instant, zero-config.',
icon: <Cloud className="h-6 w-6" />,
available: true,
badge: 'Recommended',
},
{
id: 'npm',
label: 'npm',
description: 'Publish as an npm package for local installation.',
icon: <Package className="h-6 w-6" />,
available: false,
badge: 'Coming Soon',
},
{
id: 'docker',
label: 'Docker',
description: 'Build a Docker image for self-hosted deployment.',
icon: <Container className="h-6 w-6" />,
available: false,
badge: 'Coming Soon',
},
{
id: 'download',
label: 'Download',
description: 'Download source code with all config files.',
icon: <Download className="h-6 w-6" />,
available: true,
},
];
// ── Append log ─────────────────────────────────────────────────────────
const addLog = useCallback(
(message: string, level: LogEntry['level'] = 'info') => {
setLogs((prev) => [
...prev,
{ timestamp: new Date().toISOString(), message, level },
]);
},
[],
);
// ── Start deploy ───────────────────────────────────────────────────────
const startDeploy = useCallback(
async (target: DeployTarget) => {
setSelectedTarget(target);
setPhase('deploying');
setLogs([]);
setError(null);
setResult(null);
setStepStates({
build: 'active',
test: 'waiting',
package: 'waiting',
deploy: 'waiting',
verify: 'waiting',
});
addLog(`Starting deployment to ${target}`, 'info');
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch('/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId,
target,
config: {
slug: projectSlug ?? projectId.slice(0, 12),
},
}),
signal: controller.signal,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error ?? `HTTP ${res.status}`);
}
// Read SSE stream
const reader = res.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const event = JSON.parse(line.slice(6));
handleEvent(event);
} catch {
// Skip malformed events
}
}
}
} catch (err: any) {
if (err.name === 'AbortError') return;
const msg = err.message ?? 'Deployment failed';
addLog(msg, 'error');
setError(msg);
setPhase('error');
// Mark current active step as failed
setStepStates((prev) => {
const next = { ...prev };
for (const step of STEPS) {
if (next[step.key] === 'active') {
next[step.key] = 'failed';
}
}
return next;
});
}
},
[projectId, projectSlug, addLog],
);
// ── Handle SSE event ───────────────────────────────────────────────────
const handleEvent = useCallback(
(event: any) => {
const { type, step, message, level, result: eventResult } = event;
// Add to log
if (message) {
addLog(message, level ?? 'info');
}
// Update step states
if (step && type === 'progress') {
setStepStates((prev) => {
const next = { ...prev };
// Complete all steps before current
const stepIdx = STEPS.findIndex((s) => s.key === step);
for (let i = 0; i < stepIdx; i++) {
if (next[STEPS[i].key] !== 'failed') {
next[STEPS[i].key] = 'complete';
}
}
// Set current step based on level
if (level === 'success') {
next[step as DeployStep] = 'complete';
// Activate next step
if (stepIdx + 1 < STEPS.length) {
next[STEPS[stepIdx + 1].key] = 'active';
}
} else if (level === 'error') {
next[step as DeployStep] = 'failed';
} else {
next[step as DeployStep] = 'active';
}
return next;
});
}
// Deploy complete
if (type === 'complete' && eventResult) {
setResult(eventResult);
setStepStates({
build: 'complete',
test: 'complete',
package: 'complete',
deploy: 'complete',
verify: 'complete',
});
setTimeout(() => setPhase('success'), 500);
}
// Error
if (type === 'error') {
setError(message);
setPhase('error');
}
},
[addLog],
);
// ── Reset ──────────────────────────────────────────────────────────────
const reset = () => {
abortRef.current?.abort();
setPhase('select');
setSelectedTarget(null);
setLogs([]);
setError(null);
setResult(null);
setStepStates({
build: 'waiting',
test: 'waiting',
package: 'waiting',
deploy: 'waiting',
verify: 'waiting',
});
};
// ── Render: Target selection ───────────────────────────────────────────
if (phase === 'select') {
return (
<div className={cn('mx-auto max-w-3xl', className)}>
<div className="mb-8 text-center">
<div className="mb-4 inline-flex items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 p-4">
<Rocket className="h-8 w-8 text-indigo-400" />
</div>
<h1 className="mb-2 text-3xl font-bold text-white">
Deploy {projectName}
</h1>
<p className="text-gray-400">
Choose a deployment target for your MCP server
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{targets.map((t) => (
<button
key={t.id}
disabled={!t.available}
onClick={() => t.available && startDeploy(t.id)}
className={cn(
'group relative flex flex-col items-start gap-3 rounded-2xl border p-6 text-left transition-all duration-200',
t.available
? 'border-gray-700 bg-gray-800/50 hover:border-indigo-500/50 hover:bg-gray-800 hover:shadow-lg hover:shadow-indigo-500/10 cursor-pointer'
: 'border-gray-800 bg-gray-900/50 opacity-60 cursor-not-allowed',
)}
>
{/* Badge */}
{t.badge && (
<span
className={cn(
'absolute right-4 top-4 rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider',
t.badge === 'Recommended'
? 'bg-indigo-500/20 text-indigo-400'
: 'bg-gray-700 text-gray-400',
)}
>
{t.badge}
</span>
)}
{/* Icon */}
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-xl transition-colors',
t.available
? 'bg-indigo-500/10 text-indigo-400 group-hover:bg-indigo-500/20'
: 'bg-gray-800 text-gray-500',
)}
>
{t.icon}
</div>
{/* Label & description */}
<div>
<h3 className="text-lg font-semibold text-white">
{t.label}
</h3>
<p className="mt-1 text-sm text-gray-400">{t.description}</p>
</div>
</button>
))}
</div>
</div>
);
}
// ── Render: Deploying / Error ──────────────────────────────────────────
if (phase === 'deploying' || phase === 'error') {
return (
<div className={cn('mx-auto max-w-3xl', className)}>
{/* Back button */}
<button
onClick={reset}
className="mb-6 flex items-center gap-2 text-sm text-gray-400 transition-colors hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
Back to targets
</button>
{/* Header */}
<div className="mb-8 text-center">
<h1 className="mb-2 text-2xl font-bold text-white">
Deploying to{' '}
<span className="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
{selectedTarget}
</span>
</h1>
<p className="text-gray-400">{projectName}</p>
</div>
{/* Step indicator */}
<div className="mb-8 flex items-start justify-center">
{STEPS.map((step, i) => (
<DeployStepIndicator
key={step.key}
label={step.label}
state={stepStates[step.key]}
index={i}
isLast={i === STEPS.length - 1}
/>
))}
</div>
{/* Error banner */}
{phase === 'error' && error && (
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
<strong>Error:</strong> {error}
</div>
)}
{/* Log viewer */}
<DeployLogViewer logs={logs} maxHeight="360px" />
{/* Retry on error */}
{phase === 'error' && selectedTarget && (
<div className="mt-6 flex justify-center gap-3">
<button
onClick={reset}
className="rounded-xl border border-gray-700 bg-gray-800 px-5 py-2.5 text-sm font-medium text-gray-300 hover:bg-gray-700"
>
Back
</button>
<button
onClick={() => startDeploy(selectedTarget)}
className="rounded-xl bg-gradient-to-r from-indigo-600 to-purple-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-indigo-500/25 hover:shadow-indigo-500/40"
>
Retry Deploy
</button>
</div>
)}
</div>
);
}
// ── Render: Success ────────────────────────────────────────────────────
if (phase === 'success' && result) {
return (
<div className={cn('mx-auto max-w-3xl', className)}>
<DeploySuccess
result={result}
serverName={projectName}
onDashboard={() => {
window.location.href = `/projects/${projectId}`;
}}
onViewServer={() => {
if (result.url) window.open(result.url, '_blank');
}}
onDeployAnother={reset}
/>
</div>
);
}
return null;
}