456 lines
14 KiB
TypeScript
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;
|
|
}
|