350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import type { AppConfig, DeployServer, GitServer } from '../api/client';
|
||
import { api } from '../api/client';
|
||
|
||
export default function SettingsPage() {
|
||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [message, setMessage] = useState('');
|
||
|
||
useEffect(() => {
|
||
api.getConfig().then(c => { setConfig(c); setLoading(false); }).catch(() => setLoading(false));
|
||
}, []);
|
||
|
||
async function handleSave(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!config) return;
|
||
setSaving(true);
|
||
setMessage('');
|
||
try {
|
||
await api.updateConfig({
|
||
FOXY_WORKSPACE: config.FOXY_WORKSPACE,
|
||
GITEA_SERVER: config.GITEA_SERVER,
|
||
DEPLOYMENT_SERVER: config.DEPLOYMENT_SERVER,
|
||
DEPLOYMENT_USER: config.DEPLOYMENT_USER,
|
||
LOG_LEVEL: config.LOG_LEVEL,
|
||
});
|
||
setMessage('✅ Configuration sauvegardée');
|
||
} catch (err) {
|
||
setMessage('❌ ' + (err instanceof Error ? err.message : 'Erreur'));
|
||
}
|
||
setSaving(false);
|
||
}
|
||
|
||
if (loading || !config) {
|
||
return <div className="flex items-center justify-center h-64"><div className="text-fox-500 animate-spin-slow text-4xl">🦊</div></div>;
|
||
}
|
||
|
||
const fields: { key: keyof AppConfig; label: string; icon: string; editable: boolean; secret?: boolean }[] = [
|
||
{ key: 'FOXY_WORKSPACE', label: 'Workspace Foxy (Conteneur)', icon: '📁', editable: true },
|
||
{ key: 'GITEA_SERVER', label: 'Serveur Gitea', icon: '🌐', editable: true },
|
||
{ key: 'GITEA_OPENCLAW_TOKEN', label: 'Token Gitea', icon: '🔑', editable: false, secret: true },
|
||
{ key: 'DEPLOYMENT_SERVER', label: 'Serveur de déploiement', icon: '🖥️', editable: true },
|
||
{ key: 'DEPLOYMENT_USER', label: 'Utilisateur déploiement', icon: '👤', editable: true },
|
||
{ key: 'DEPLOYMENT_PWD', label: 'Mot de passe déploiement', icon: '🔒', editable: false, secret: true },
|
||
{ key: 'TELEGRAM_BOT_TOKEN', label: 'Token Telegram', icon: '🤖', editable: false, secret: true },
|
||
{ key: 'TELEGRAM_CHAT_ID', label: 'Chat ID Telegram', icon: '💬', editable: true },
|
||
{ key: 'LOG_LEVEL', label: 'Niveau de log', icon: '📊', editable: true },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
<h1 className="text-xl font-bold text-white">⚙️ Configuration</h1>
|
||
|
||
{/* Global config */}
|
||
<form onSubmit={handleSave} className="glass-card p-6 space-y-4">
|
||
<h2 className="text-lg font-bold text-white">🔧 Paramètres généraux</h2>
|
||
{message && (
|
||
<div className={`text-sm p-3 rounded-lg ${message.startsWith('✅') ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||
{message}
|
||
</div>
|
||
)}
|
||
|
||
{fields.map(f => (
|
||
<div key={f.key}>
|
||
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 flex items-center gap-2">
|
||
<span>{f.icon}</span> {f.label}
|
||
{f.secret && <span className="text-fox-500 text-[10px]">(protégé)</span>}
|
||
</label>
|
||
<input
|
||
className="input"
|
||
value={config[f.key]}
|
||
onChange={e => setConfig({ ...config, [f.key]: e.target.value })}
|
||
disabled={!f.editable}
|
||
type={f.secret ? 'password' : 'text'}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
<div className="pt-2">
|
||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||
{saving ? '⏳ Sauvegarde...' : '💾 Sauvegarder'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Deploy Servers */}
|
||
<DeployServerManager />
|
||
|
||
{/* Git Servers */}
|
||
<GitServerManager />
|
||
|
||
{/* Workflows info */}
|
||
<WorkflowsInfo />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Deploy Server Manager
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function DeployServerManager() {
|
||
const [servers, setServers] = useState<DeployServer[]>([]);
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [form, setForm] = useState({ name: '', host: '', user: 'deploy', password: '', ssh_port: 22, description: '' });
|
||
const [error, setError] = useState('');
|
||
|
||
async function fetchServers() {
|
||
try { setServers(await api.listDeployServers()); } catch { /* ignore */ }
|
||
}
|
||
useEffect(() => { fetchServers(); }, []);
|
||
|
||
async function handleAdd(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setError('');
|
||
try {
|
||
await api.createDeployServer({
|
||
...form,
|
||
password: form.password || undefined,
|
||
description: form.description || undefined,
|
||
});
|
||
setForm({ name: '', host: '', user: 'deploy', password: '', ssh_port: 22, description: '' });
|
||
setShowAdd(false);
|
||
fetchServers();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Erreur');
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: number, name: string) {
|
||
if (!confirm(`Supprimer le serveur "${name}" ?`)) return;
|
||
try {
|
||
await api.deleteDeployServer(id);
|
||
fetchServers();
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : 'Erreur');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="glass-card p-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-bold text-white">🖥️ Serveurs de déploiement</h2>
|
||
<button className="btn btn-ghost text-xs" onClick={() => setShowAdd(!showAdd)}>
|
||
{showAdd ? '✕ Fermer' : '+ Ajouter'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Add form */}
|
||
{showAdd && (
|
||
<form onSubmit={handleAdd} className="p-4 rounded-xl bg-surface-800/50 space-y-3">
|
||
{error && <div className="text-red-400 text-sm bg-red-400/10 p-2 rounded-lg">{error}</div>}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Nom</label>
|
||
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required placeholder="prod-server" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Hôte (IP / hostname)</label>
|
||
<input className="input" value={form.host} onChange={e => setForm({...form, host: e.target.value})} required placeholder="192.168.1.100" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Utilisateur SSH</label>
|
||
<input className="input" value={form.user} onChange={e => setForm({...form, user: e.target.value})} placeholder="deploy" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Mot de passe SSH</label>
|
||
<input className="input" type="password" value={form.password} onChange={e => setForm({...form, password: e.target.value})} placeholder="(optionnel)" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Port SSH</label>
|
||
<input className="input" type="number" value={form.ssh_port} onChange={e => setForm({...form, ssh_port: Number(e.target.value)})} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Description</label>
|
||
<input className="input" value={form.description} onChange={e => setForm({...form, description: e.target.value})} placeholder="Serveur production" />
|
||
</div>
|
||
</div>
|
||
<button type="submit" className="btn btn-primary text-sm">💾 Ajouter le serveur</button>
|
||
</form>
|
||
)}
|
||
|
||
{/* Server list */}
|
||
{servers.length === 0 ? (
|
||
<p className="text-gray-500 text-sm">Aucun serveur de déploiement configuré.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{servers.map(s => (
|
||
<div key={s.id} className="flex items-center justify-between p-3 rounded-xl bg-surface-800/50">
|
||
<div>
|
||
<span className="text-white font-semibold">{s.name}</span>
|
||
<span className="text-gray-500 text-sm ml-3">{s.user}@{s.host}:{s.ssh_port}</span>
|
||
{s.description && <span className="text-gray-600 text-xs ml-3">— {s.description}</span>}
|
||
</div>
|
||
<button className="text-red-400 hover:text-red-300 text-sm" onClick={() => handleDelete(s.id, s.name)}>🗑 Supprimer</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Git Server Manager
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function GitServerManager() {
|
||
const [servers, setServers] = useState<GitServer[]>([]);
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [form, setForm] = useState({ name: '', url: '', token: '', org: 'openclaw', description: '' });
|
||
const [error, setError] = useState('');
|
||
|
||
async function fetchServers() {
|
||
try { setServers(await api.listGitServers()); } catch { /* ignore */ }
|
||
}
|
||
useEffect(() => { fetchServers(); }, []);
|
||
|
||
async function handleAdd(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setError('');
|
||
try {
|
||
await api.createGitServer({
|
||
...form,
|
||
token: form.token || undefined,
|
||
description: form.description || undefined,
|
||
});
|
||
setForm({ name: '', url: '', token: '', org: 'openclaw', description: '' });
|
||
setShowAdd(false);
|
||
fetchServers();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Erreur');
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: number, name: string) {
|
||
if (!confirm(`Supprimer le serveur Git "${name}" ?`)) return;
|
||
try {
|
||
await api.deleteGitServer(id);
|
||
fetchServers();
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : 'Erreur');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="glass-card p-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-bold text-white">🌐 Serveurs Git</h2>
|
||
<button className="btn btn-ghost text-xs" onClick={() => setShowAdd(!showAdd)}>
|
||
{showAdd ? '✕ Fermer' : '+ Ajouter'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Add form */}
|
||
{showAdd && (
|
||
<form onSubmit={handleAdd} className="p-4 rounded-xl bg-surface-800/50 space-y-3">
|
||
{error && <div className="text-red-400 text-sm bg-red-400/10 p-2 rounded-lg">{error}</div>}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Nom</label>
|
||
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required placeholder="gitea-local" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">URL</label>
|
||
<input className="input" value={form.url} onChange={e => setForm({...form, url: e.target.value})} required placeholder="https://git.example.com" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Organisation</label>
|
||
<input className="input" value={form.org} onChange={e => setForm({...form, org: e.target.value})} placeholder="openclaw" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Token API</label>
|
||
<input className="input" type="password" value={form.token} onChange={e => setForm({...form, token: e.target.value})} placeholder="(optionnel)" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 mb-1 block">Description</label>
|
||
<input className="input" value={form.description} onChange={e => setForm({...form, description: e.target.value})} placeholder="Gitea local" />
|
||
</div>
|
||
</div>
|
||
<button type="submit" className="btn btn-primary text-sm">💾 Ajouter le serveur Git</button>
|
||
</form>
|
||
)}
|
||
|
||
{/* Server list */}
|
||
{servers.length === 0 ? (
|
||
<p className="text-gray-500 text-sm">Aucun serveur Git configuré.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{servers.map(s => (
|
||
<div key={s.id} className="flex items-center justify-between p-3 rounded-xl bg-surface-800/50">
|
||
<div>
|
||
<span className="text-white font-semibold">{s.name}</span>
|
||
<span className="text-gray-500 text-sm ml-3">{s.url}</span>
|
||
<span className="text-gray-600 text-xs ml-2">(org: {s.org})</span>
|
||
{s.description && <span className="text-gray-600 text-xs ml-3">— {s.description}</span>}
|
||
</div>
|
||
<button className="text-red-400 hover:text-red-300 text-sm" onClick={() => handleDelete(s.id, s.name)}>🗑 Supprimer</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Workflows Info
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function WorkflowsInfo() {
|
||
const [workflows, setWorkflows] = useState<{ type: string; label: string; path: string; steps: { agent: string; model: string }[] }[]>([]);
|
||
|
||
useEffect(() => {
|
||
api.listWorkflows().then(setWorkflows).catch(() => {});
|
||
}, []);
|
||
|
||
if (workflows.length === 0) return null;
|
||
|
||
return (
|
||
<div className="glass-card p-6">
|
||
<h2 className="text-lg font-bold text-white mb-4">🔄 Workflows disponibles</h2>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{workflows.map(wf => (
|
||
<div key={wf.type} className="p-4 rounded-xl bg-surface-800/50">
|
||
<h3 className="text-white font-semibold mb-1">{wf.label}</h3>
|
||
<p className="text-xs text-gray-500 mb-3 font-mono">{wf.path}</p>
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{wf.steps.map((s, i) => (
|
||
<div key={i} className="flex items-center gap-1">
|
||
<span className="badge bg-fox-600/15 text-fox-400 text-[10px]">{s.agent}</span>
|
||
{i < wf.steps.length - 1 && <span className="text-gray-600">→</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|