feat: Implement comprehensive project management with new API client, backend routes, and frontend pages for projects and settings.

This commit is contained in:
2026-03-13 08:54:45 -04:00
parent 18c8815166
commit a927534d16
9 changed files with 815 additions and 78 deletions
+63 -2
View File
@@ -13,9 +13,15 @@ export interface Project {
description: string;
status: string;
workflow_type: string;
project_type: string;
test_mode: boolean;
install_path?: string;
gitea_repo?: string;
deployment_target?: string;
deploy_server_id?: number;
git_server_id?: number;
deploy_server_name?: string;
git_server_name?: string;
created_at: string;
updated_at: string;
tasks: Task[];
@@ -29,9 +35,13 @@ export interface ProjectSummary {
slug: string;
status: string;
workflow_type: string;
project_type: string;
test_mode: boolean;
install_path?: string;
task_count: number;
tasks_done: number;
deploy_server_name?: string;
git_server_name?: string;
created_at: string;
updated_at: string;
}
@@ -89,6 +99,7 @@ export interface AgentStatus {
export interface WorkflowDef {
type: string;
label: string;
path: string;
steps: {
agent: string;
label: string;
@@ -98,6 +109,32 @@ export interface WorkflowDef {
}[];
}
export interface ProjectTypeInfo {
value: string;
emoji: string;
label: string;
description: string;
}
export interface DeployServer {
id: number;
name: string;
host: string;
user: string;
ssh_port: number;
description: string;
created_at: string;
}
export interface GitServer {
id: number;
name: string;
url: string;
org: string;
description: string;
created_at: string;
}
export interface AppConfig {
OPENCLAW_WORKSPACE: string;
GITEA_SERVER: string;
@@ -127,7 +164,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
return res.json();
}
// ─── Projects ───────────────────────────────────────────────────────────────
// ─── API ────────────────────────────────────────────────────────────────────
export const api = {
// Projects
@@ -136,7 +173,16 @@ export const api = {
return request<ProjectSummary[]>(`/api/projects${qs}`);
},
getProject: (id: number) => request<Project>(`/api/projects/${id}`),
createProject: (data: { name: string; description: string; workflow_type: string; test_mode?: boolean }) =>
createProject: (data: {
name: string;
description: string;
workflow_type: string;
project_type: string;
test_mode?: boolean;
install_path?: string;
deploy_server_id?: number | null;
git_server_id?: number | null;
}) =>
request<Project>('/api/projects', { method: 'POST', body: JSON.stringify(data) }),
startProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/start`, { method: 'POST' }),
pauseProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/pause`, { method: 'POST' }),
@@ -145,6 +191,9 @@ export const api = {
deleteProject: (id: number) => request<{ message: string }>(`/api/projects/${id}`, { method: 'DELETE' }),
getProgress: (id: number) => request<{ current_step: number; total_steps: number; percentage: number }>(`/api/projects/${id}/progress`),
// Project Types
listProjectTypes: () => request<ProjectTypeInfo[]>('/api/projects/types'),
// Agents
listAgents: () => request<AgentStatus[]>('/api/agents'),
getAgentHistory: (name: string) => request<AgentExecution[]>(`/api/agents/${name}/history`),
@@ -163,6 +212,18 @@ export const api = {
updateConfig: (data: Record<string, string>) =>
request<{ message: string }>('/api/config', { method: 'PUT', body: JSON.stringify(data) }),
// Deploy Servers
listDeployServers: () => request<DeployServer[]>('/api/config/deploy-servers'),
createDeployServer: (data: { name: string; host: string; user?: string; password?: string; ssh_port?: number; description?: string }) =>
request<DeployServer>('/api/config/deploy-servers', { method: 'POST', body: JSON.stringify(data) }),
deleteDeployServer: (id: number) => request<{ message: string }>(`/api/config/deploy-servers/${id}`, { method: 'DELETE' }),
// Git Servers
listGitServers: () => request<GitServer[]>('/api/config/git-servers'),
createGitServer: (data: { name: string; url: string; token?: string; org?: string; description?: string }) =>
request<GitServer>('/api/config/git-servers', { method: 'POST', body: JSON.stringify(data) }),
deleteGitServer: (id: number) => request<{ message: string }>(`/api/config/git-servers/${id}`, { method: 'DELETE' }),
// Health
health: () => request<{ status: string; version: string }>('/api/health'),
};
+1 -1
View File
@@ -14,7 +14,7 @@ type WSCallback = (msg: WSMessage) => void;
export function useWebSocket(onMessage: WSCallback) {
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+170 -30
View File
@@ -1,14 +1,7 @@
import { useEffect, useState } from 'react';
import type { ProjectSummary } from '../api/client';
import type { ProjectSummary, ProjectTypeInfo, WorkflowDef, DeployServer, GitServer } from '../api/client';
import { api } from '../api/client';
const WORKFLOW_OPTIONS = [
{ value: 'SOFTWARE_DESIGN', label: '🏗️ Conception logicielle' },
{ value: 'SYSADMIN_DEBUG', label: '🐛 Débogage Sysadmin' },
{ value: 'DEVOPS_SETUP', label: '🐳 DevOps Setup' },
{ value: 'SYSADMIN_ADJUST', label: '🔧 Ajustement Sysadmin' },
];
function getStatusBadge(s: string) {
if (s.endsWith('_RUNNING')) return 'badge-running';
if (s.startsWith('AWAITING_')) return 'badge-awaiting';
@@ -17,13 +10,30 @@ function getStatusBadge(s: string) {
return 'badge-paused';
}
const DEFAULT_FORM = {
name: '',
description: '',
workflow_type: 'SOFTWARE_DESIGN',
project_type: 'DOCKER_WEBAPP',
test_mode: false,
install_path: '',
deploy_server_id: null as number | null,
git_server_id: null as number | null,
};
export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ name: '', description: '', workflow_type: 'SOFTWARE_DESIGN', test_mode: false });
const [form, setForm] = useState({ ...DEFAULT_FORM });
const [error, setError] = useState('');
// Lookup data for dropdowns
const [projectTypes, setProjectTypes] = useState<ProjectTypeInfo[]>([]);
const [workflows, setWorkflows] = useState<WorkflowDef[]>([]);
const [deployServers, setDeployServers] = useState<DeployServer[]>([]);
const [gitServers, setGitServers] = useState<GitServer[]>([]);
async function fetchProjects() {
try {
setProjects(await api.listProjects());
@@ -31,15 +41,41 @@ export default function ProjectsPage() {
setLoading(false);
}
useEffect(() => { fetchProjects(); }, []);
async function fetchDropdownData() {
try {
const [pt, wf, ds, gs] = await Promise.all([
api.listProjectTypes(),
api.listWorkflows(),
api.listDeployServers(),
api.listGitServers(),
]);
setProjectTypes(pt);
setWorkflows(wf);
setDeployServers(ds);
setGitServers(gs);
} catch { /* ignore */ }
}
useEffect(() => {
fetchProjects();
fetchDropdownData();
}, []);
const selectedWorkflow = workflows.find(w => w.type === form.workflow_type);
const selectedProjectType = projectTypes.find(pt => pt.value === form.project_type);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setError('');
try {
await api.createProject(form);
await api.createProject({
...form,
install_path: form.install_path || undefined,
deploy_server_id: form.deploy_server_id || undefined,
git_server_id: form.git_server_id || undefined,
});
setShowCreate(false);
setForm({ name: '', description: '', workflow_type: 'SOFTWARE_DESIGN', test_mode: false });
setForm({ ...DEFAULT_FORM });
fetchProjects();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur');
@@ -59,6 +95,14 @@ export default function ProjectsPage() {
}
}
// Lookup emoji for a project type value
function typeEmoji(val: string) {
return projectTypes.find(pt => pt.value === val)?.emoji || '📦';
}
function typeLabel(val: string) {
return projectTypes.find(pt => pt.value === val)?.label || val;
}
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="text-fox-500 animate-spin-slow text-4xl">🦊</div></div>;
}
@@ -75,28 +119,120 @@ export default function ProjectsPage() {
{/* Create form */}
{showCreate && (
<form onSubmit={handleCreate} className="glass-card p-6 space-y-4">
<h2 className="text-lg font-bold text-white">Nouveau projet</h2>
<form onSubmit={handleCreate} className="glass-card p-6 space-y-5">
<h2 className="text-lg font-bold text-white">🦊 Nouveau projet</h2>
{error && <div className="text-red-400 text-sm bg-red-400/10 p-3 rounded-lg">{error}</div>}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Nom</label>
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required />
{/* Row 1: Name + Description */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Nom du projet</label>
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required placeholder="mon-super-projet" />
</div>
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Description</label>
<input className="input" value={form.description} onChange={e => setForm({...form, description: e.target.value})} placeholder="Description courte du projet..." />
</div>
</div>
{/* Row 2: Project Type */}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Description</label>
<textarea className="input min-h-20" value={form.description} onChange={e => setForm({...form, description: e.target.value})} />
</div>
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Workflow</label>
<select className="input" value={form.workflow_type} onChange={e => setForm({...form, workflow_type: e.target.value})}>
{WORKFLOW_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🎯 Type de projet</label>
<select
className="input"
value={form.project_type}
onChange={e => setForm({...form, project_type: e.target.value})}
>
{projectTypes.map(pt => (
<option key={pt.value} value={pt.value}>{pt.emoji} {pt.label}</option>
))}
</select>
{/* Description of selected type */}
{selectedProjectType && (
<p className="text-xs text-gray-500 mt-1.5 ml-1">{selectedProjectType.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="test_mode" checked={form.test_mode} onChange={e => setForm({...form, test_mode: e.target.checked})} className="accent-fox-500" />
<label htmlFor="test_mode" className="text-sm text-gray-400">Mode test (simulation sans code réel)</label>
{/* Row 3: Workflow */}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🔄 Workflow</label>
<select
className="input"
value={form.workflow_type}
onChange={e => setForm({...form, workflow_type: e.target.value})}
>
{workflows.map(wf => (
<option key={wf.type} value={wf.type}>{wf.label}</option>
))}
</select>
{/* Path visualization */}
{selectedWorkflow && (
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{selectedWorkflow.steps.map((s, i) => (
<span key={i} className="flex items-center gap-1.5">
<span className="badge bg-fox-600/15 text-fox-400 text-[11px]">{s.agent}</span>
{i < selectedWorkflow.steps.length - 1 && <span className="text-gray-600 text-xs">→</span>}
</span>
))}
</div>
)}
</div>
{/* Row 4: Install path */}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">📁 Emplacement d'installation</label>
<input
className="input"
value={form.install_path}
onChange={e => setForm({...form, install_path: e.target.value})}
placeholder="/home/openclaw/projects/mon-projet"
/>
</div>
{/* Row 5: Servers */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🖥️ Serveur de déploiement</label>
<select
className="input"
value={form.deploy_server_id ?? ''}
onChange={e => setForm({...form, deploy_server_id: e.target.value ? Number(e.target.value) : null})}
>
<option value="">— Aucun —</option>
{deployServers.map(s => (
<option key={s.id} value={s.id}>{s.name} ({s.host})</option>
))}
</select>
{deployServers.length === 0 && (
<p className="text-xs text-gray-600 mt-1">Aucun serveur configuré. Ajoutez-en dans Config → Serveurs.</p>
)}
</div>
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🌐 Serveur Git</label>
<select
className="input"
value={form.git_server_id ?? ''}
onChange={e => setForm({...form, git_server_id: e.target.value ? Number(e.target.value) : null})}
>
<option value="">— Aucun (pas de dépôt Git) —</option>
{gitServers.map(s => (
<option key={s.id} value={s.id}>{s.name} ({s.url})</option>
))}
</select>
{gitServers.length === 0 && (
<p className="text-xs text-gray-600 mt-1">Aucun serveur Git configuré. Ajoutez-en dans Config → Serveurs.</p>
)}
</div>
</div>
{/* Row 6: Test mode + Submit */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<input type="checkbox" id="test_mode" checked={form.test_mode} onChange={e => setForm({...form, test_mode: e.target.checked})} className="accent-fox-500" />
<label htmlFor="test_mode" className="text-sm text-gray-400">Mode test (simulation sans code réel)</label>
</div>
<button type="submit" className="btn btn-primary">🚀 Créer le projet</button>
</div>
<button type="submit" className="btn btn-primary">Créer le projet</button>
</form>
)}
@@ -113,16 +249,20 @@ export default function ProjectsPage() {
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<span className="text-lg" title={typeLabel(p.project_type)}>{typeEmoji(p.project_type)}</span>
<h3 className="text-white font-semibold truncate">{p.name}</h3>
<span className={`badge ${getStatusBadge(p.status)}`}>
{p.status.replace(/_/g, ' ')}
</span>
{p.test_mode && <span className="badge bg-purple-500/15 text-purple-400">TEST</span>}
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>📁 {p.slug}</span>
<div className="flex items-center gap-4 text-xs text-gray-500 flex-wrap">
<span title={typeLabel(p.project_type)}>📦 {typeLabel(p.project_type)}</span>
<span>🔄 {p.workflow_type.replace(/_/g, ' ')}</span>
<span>📊 {p.tasks_done}/{p.task_count} tâches</span>
{p.deploy_server_name && <span>🖥️ {p.deploy_server_name}</span>}
{p.git_server_name && <span>🌐 {p.git_server_name}</span>}
{p.install_path && <span>📁 {p.install_path}</span>}
<span>🕐 {new Date(p.updated_at).toLocaleString('fr-FR')}</span>
</div>
{/* Progress bar */}
+232 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import type { AppConfig } from '../api/client';
import type { AppConfig, DeployServer, GitServer } from '../api/client';
import { api } from '../api/client';
export default function SettingsPage() {
@@ -52,7 +52,9 @@ export default function SettingsPage() {
<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}
@@ -82,14 +84,240 @@ export default function SettingsPage() {
</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; steps: { agent: string; model: string }[] }[]>([]);
const [workflows, setWorkflows] = useState<{ type: string; label: string; path: string; steps: { agent: string; model: string }[] }[]>([]);
useEffect(() => {
api.listWorkflows().then(setWorkflows).catch(() => {});
@@ -103,7 +331,8 @@ function WorkflowsInfo() {
<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-3">{wf.label}</h3>
<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">