227 lines
12 KiB
JavaScript
227 lines
12 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
const AppDetail = ({ app, onBack }) => {
|
|
const [formData, setFormData] = useState({
|
|
domain: '',
|
|
dbPassword: '',
|
|
port: app.vhost?.proxyPort || ''
|
|
});
|
|
const [installing, setInstalling] = useState(false);
|
|
const [logs, setLogs] = useState([]);
|
|
const [status, setStatus] = useState(null); // 'success' | 'error'
|
|
const logEndRef = useRef(null);
|
|
|
|
const scrollToBottom = () => {
|
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [logs]);
|
|
|
|
const handleInstall = async (e) => {
|
|
e.preventDefault();
|
|
setInstalling(true);
|
|
setLogs([]);
|
|
setStatus(null);
|
|
|
|
try {
|
|
// Use fetch with stream reader to handle POST with SSE-like response
|
|
const response = await fetch(`/api/v1/apps/${app.id}/install`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
|
|
const chunk = decoder.decode(value);
|
|
const lines = chunk.split('\n');
|
|
|
|
lines.forEach(line => {
|
|
if (line.trim().startsWith('data: ')) {
|
|
try {
|
|
const data = JSON.parse(line.trim().slice(6));
|
|
if (data.log) {
|
|
setLogs(prev => [...prev, data.log]);
|
|
}
|
|
if (data.completed) {
|
|
setStatus(data.success ? 'success' : 'error');
|
|
if (data.error) setLogs(prev => [...prev, `ERROR: ${data.error}`]);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error parsing SSE line', e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
setLogs(prev => [...prev, `FATAL: ${err.message}`]);
|
|
setStatus('error');
|
|
} finally {
|
|
setInstalling(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={onBack}
|
|
className="flex items-center text-sm text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Back to Marketplace
|
|
</button>
|
|
<div className="px-3 py-1 bg-gray-800 rounded-full text-xs font-medium text-blue-400 border border-blue-500/20">
|
|
{app.category}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-900/50 border border-gray-800 rounded-2xl overflow-hidden">
|
|
<div className="p-8 border-b border-gray-800 flex items-start gap-6">
|
|
<div className="w-20 h-20 bg-gray-800 rounded-2xl flex items-center justify-center text-4xl shadow-inner">
|
|
{app.id === 'forgejo' ? '🏗️' : '📦'}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h1 className="text-3xl font-bold text-white">{app.name}</h1>
|
|
<span className="px-2 py-0.5 bg-gray-800 text-gray-400 rounded text-xs font-mono">v{app.version}</span>
|
|
</div>
|
|
<p className="text-gray-400 leading-relaxed max-w-2xl">{app.description}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-8">
|
|
{app.installed ? (
|
|
<div className="space-y-6">
|
|
<div className="p-6 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-green-400 font-bold mb-1">Application Installed</h3>
|
|
<p className="text-sm text-green-400/70">
|
|
Deployed to <b>{app.installedInfo?.domain}</b> on {new Date(app.installedInfo?.installedAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<a
|
|
href={`https://${app.installedInfo?.domain}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="px-6 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg font-bold transition-all"
|
|
>
|
|
Open Application
|
|
</a>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
{/* Install Form */}
|
|
<form onSubmit={handleInstall} className="space-y-6">
|
|
<h3 className="text-lg font-bold text-white mb-4">Installation Settings</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Target Domain</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
placeholder="e.g. git.example.com"
|
|
value={formData.domain}
|
|
onChange={e => setFormData({ ...formData, domain: e.target.value })}
|
|
disabled={installing}
|
|
className="w-full bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
{app.database && (
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Database Password</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
placeholder="Secure password for DB"
|
|
value={formData.dbPassword}
|
|
onChange={e => setFormData({ ...formData, dbPassword: e.target.value })}
|
|
disabled={installing}
|
|
className="w-full bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 transition-colors"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{app.vhost && (
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Port Override (Optional)</label>
|
|
<input
|
|
type="number"
|
|
placeholder={app.vhost.proxyPort}
|
|
value={formData.port}
|
|
onChange={e => setFormData({ ...formData, port: e.target.value })}
|
|
disabled={installing}
|
|
className="w-full bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 transition-colors"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={installing}
|
|
className={`w-full py-4 rounded-xl font-bold text-lg transition-all ${installing
|
|
? 'bg-gray-800 text-gray-500 cursor-not-allowed'
|
|
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-500/20'
|
|
}`}
|
|
>
|
|
{installing ? 'Installing...' : 'Install Now'}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Live Logs */}
|
|
<div className="flex flex-col h-full min-h-[400px]">
|
|
<h3 className="text-lg font-bold text-white mb-4">Installation Logs</h3>
|
|
<div className="flex-1 bg-black/50 border border-gray-800 rounded-xl p-4 font-mono text-xs overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-gray-800">
|
|
{logs.length === 0 && !installing && (
|
|
<div className="h-full flex items-center justify-center text-gray-600 italic">
|
|
Logs will appear here during installation...
|
|
</div>
|
|
)}
|
|
{logs.map((log, i) => (
|
|
<div key={i} className={log.includes('ERROR') ? 'text-red-400' : 'text-gray-300'}>
|
|
{log}
|
|
</div>
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
|
|
{status === 'success' && (
|
|
<div className="mt-4 p-4 bg-green-500/20 border border-green-500/50 rounded-xl text-green-400 text-sm font-bold flex items-center">
|
|
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Installation Complete!
|
|
</div>
|
|
)}
|
|
{status === 'error' && (
|
|
<div className="mt-4 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm font-bold flex items-center">
|
|
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
Installation Failed.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AppDetail;
|