feat: Implement comprehensive project management with new API client, backend routes, and frontend pages for projects and settings.
This commit is contained in:
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user