sawa-control-panel/frontend/src/pages/AppDetail.jsx

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;