chore: install frontend dependencies.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Projects from './pages/Projects';
|
||||
import Agents from './pages/Agents';
|
||||
import Logs from './pages/Logs';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', label: 'Dashboard', icon: '📊' },
|
||||
{ path: '/projects', label: 'Projets', icon: '📋' },
|
||||
{ path: '/agents', label: 'Agents', icon: '🤖' },
|
||||
{ path: '/logs', label: 'Logs', icon: '📜' },
|
||||
{ path: '/settings', label: 'Config', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 shrink-0 bg-surface-800/60 border-r border-glass-border p-4 flex flex-col sticky top-0 h-screen">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-3 mb-8">
|
||||
<span className="text-3xl">🦊</span>
|
||||
<div>
|
||||
<div className="text-white font-extrabold text-sm tracking-tight">Foxy Dev Team</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-widest">Dashboard v2.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="space-y-1 flex-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === '/'}
|
||||
className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto pt-4 border-t border-glass-border">
|
||||
<div className="text-[10px] text-gray-600 text-center">
|
||||
Foxy Dev Team © 2026
|
||||
<br />
|
||||
Powered by OpenClaw
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* API client for the Foxy Dev Team backend.
|
||||
*/
|
||||
|
||||
const BASE_URL = '';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
status: string;
|
||||
workflow_type: string;
|
||||
test_mode: boolean;
|
||||
gitea_repo?: string;
|
||||
deployment_target?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tasks: Task[];
|
||||
audit_logs: AuditLog[];
|
||||
agent_executions: AgentExecution[];
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
workflow_type: string;
|
||||
test_mode: boolean;
|
||||
task_count: number;
|
||||
tasks_done: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
project_id: number;
|
||||
task_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
assigned_to?: string;
|
||||
status: string;
|
||||
dependencies: string[];
|
||||
acceptance_criteria?: string;
|
||||
agent_payloads: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
project_id: number;
|
||||
timestamp: string;
|
||||
agent: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
message?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AgentExecution {
|
||||
id: number;
|
||||
project_id: number;
|
||||
agent_name: string;
|
||||
status: string;
|
||||
pid?: number;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
exit_code?: number;
|
||||
error_output?: string;
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
name: string;
|
||||
display_name: string;
|
||||
model: string;
|
||||
current_status: string;
|
||||
current_project?: string;
|
||||
total_executions: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
export interface WorkflowDef {
|
||||
type: string;
|
||||
label: string;
|
||||
steps: {
|
||||
agent: string;
|
||||
label: string;
|
||||
model: string;
|
||||
awaiting_status: string;
|
||||
running_status: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
OPENCLAW_WORKSPACE: string;
|
||||
GITEA_SERVER: string;
|
||||
DEPLOYMENT_SERVER: string;
|
||||
DEPLOYMENT_USER: string;
|
||||
TELEGRAM_CHAT_ID: string;
|
||||
LOG_LEVEL: string;
|
||||
GITEA_OPENCLAW_TOKEN: string;
|
||||
DEPLOYMENT_PWD: string;
|
||||
TELEGRAM_BOT_TOKEN: string;
|
||||
}
|
||||
|
||||
// ─── HTTP Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Projects ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
// Projects
|
||||
listProjects: (params?: Record<string, string>) => {
|
||||
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
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 }) =>
|
||||
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' }),
|
||||
stopProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/stop`, { method: 'POST' }),
|
||||
resetProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/reset`, { method: 'POST' }),
|
||||
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`),
|
||||
|
||||
// Agents
|
||||
listAgents: () => request<AgentStatus[]>('/api/agents'),
|
||||
getAgentHistory: (name: string) => request<AgentExecution[]>(`/api/agents/${name}/history`),
|
||||
|
||||
// Logs
|
||||
listLogs: (params?: Record<string, string>) => {
|
||||
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
return request<AuditLog[]>(`/api/logs${qs}`);
|
||||
},
|
||||
|
||||
// Workflows
|
||||
listWorkflows: () => request<WorkflowDef[]>('/api/workflows'),
|
||||
|
||||
// Config
|
||||
getConfig: () => request<AppConfig>('/api/config'),
|
||||
updateConfig: (data: Record<string, string>) =>
|
||||
request<{ message: string }>('/api/config', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
||||
// Health
|
||||
health: () => request<{ status: string; version: string }>('/api/health'),
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* WebSocket hook for real-time updates from the backend.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
|
||||
export interface WSMessage {
|
||||
type: 'agent_status' | 'log' | 'project_update' | 'pong';
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 connect = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${window.location.host}/ws/live`;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
// Start ping interval
|
||||
const pingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send('ping');
|
||||
}, 30000);
|
||||
ws.addEventListener('close', () => clearInterval(pingInterval));
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data) as WSMessage;
|
||||
onMessage(msg);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
// Auto-reconnect after 3s
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Design Tokens ──────────────────────────────────────────────────────── */
|
||||
|
||||
@theme {
|
||||
--color-fox-50: #FFF3E0;
|
||||
--color-fox-100: #FFE0B2;
|
||||
--color-fox-200: #FFCC80;
|
||||
--color-fox-300: #FFB74D;
|
||||
--color-fox-400: #FFA726;
|
||||
--color-fox-500: #FF9800;
|
||||
--color-fox-600: #FF6D00;
|
||||
--color-fox-700: #E65100;
|
||||
|
||||
--color-surface-900: #0B0E14;
|
||||
--color-surface-800: #111621;
|
||||
--color-surface-700: #171D2E;
|
||||
--color-surface-600: #1E2538;
|
||||
--color-surface-500: #2A3248;
|
||||
|
||||
--color-glass: rgba(255, 255, 255, 0.04);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.08);
|
||||
--color-glass-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--color-success: #22C55E;
|
||||
--color-warning: #EAB308;
|
||||
--color-error: #EF4444;
|
||||
--color-info: #3B82F6;
|
||||
|
||||
--font-family-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ─── Base ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-family-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-surface-900);
|
||||
color: #E2E8F0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ──────────────────────────────────────────────────────────── */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-500);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-fox-600);
|
||||
}
|
||||
|
||||
/* ─── Glass Card ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.glass-card {
|
||||
background: var(--color-glass);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(20px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: var(--color-glass-hover);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Status Badges ──────────────────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge-running { background: rgba(59,130,246,0.15); color: #60A5FA; }
|
||||
.badge-awaiting { background: rgba(234,179,8,0.15); color: #FBBF24; }
|
||||
.badge-completed{ background: rgba(34,197,94,0.15); color: #4ADE80; }
|
||||
.badge-failed { background: rgba(239,68,68,0.15); color: #F87171; }
|
||||
.badge-paused { background: rgba(148,163,184,0.15); color: #94A3B8; }
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-fox-600), var(--color-fox-500));
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(255, 109, 0, 0.25);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba(255, 109, 0, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #DC2626, #EF4444);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--color-glass);
|
||||
color: #94A3B8;
|
||||
border: 1px solid var(--color-glass-border);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--color-glass-hover);
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
/* ─── Animations ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(255,109,0,0.2); }
|
||||
50% { box-shadow: 0 0 16px rgba(255,109,0,0.4); }
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
/* ─── Input ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.input {
|
||||
background: var(--color-surface-800);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
color: #E2E8F0;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-fox-500);
|
||||
box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ─── Sidebar ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
color: #64748B;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
color: #E2E8F0;
|
||||
background: var(--color-glass);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
color: var(--color-fox-500);
|
||||
background: rgba(255, 109, 0, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { AgentStatus } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../api/useWebSocket';
|
||||
|
||||
const AGENT_EMOJIS: Record<string, string> = {
|
||||
'Foxy-Conductor': '🎼',
|
||||
'Foxy-Architect': '🏗️',
|
||||
'Foxy-Dev': '💻',
|
||||
'Foxy-UIUX': '🎨',
|
||||
'Foxy-QA': '🔍',
|
||||
'Foxy-Admin': '🚀',
|
||||
};
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [agents, setAgents] = useState<AgentStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
try { setAgents(await api.listAgents()); } catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchAgents(); }, [fetchAgents]);
|
||||
|
||||
useWebSocket(useCallback((msg) => {
|
||||
if (msg.type === 'agent_status') fetchAgents();
|
||||
}, [fetchAgents]));
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-64"><div className="text-fox-500 animate-spin-slow text-4xl">🦊</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<h1 className="text-xl font-bold text-white">État des Agents</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{agents.map((a) => {
|
||||
const emoji = AGENT_EMOJIS[a.display_name] || '🤖';
|
||||
const successRate = a.total_executions > 0
|
||||
? Math.round((a.success_count / a.total_executions) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={a.name}
|
||||
className={`glass-card p-6 ${a.current_status === 'running' ? 'animate-pulse-glow' : ''}`}
|
||||
>
|
||||
{/* Agent header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-3xl">{emoji}</div>
|
||||
<div>
|
||||
<h3 className="text-white font-bold">{a.display_name}</h3>
|
||||
<div className="text-xs text-gray-500 font-mono">{a.model}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-4">
|
||||
<span className={`badge ${
|
||||
a.current_status === 'running' ? 'badge-running' :
|
||||
a.current_status === 'failed' ? 'badge-failed' : 'badge-paused'
|
||||
}`}>
|
||||
{a.current_status === 'running' ? '⚡ En cours' :
|
||||
a.current_status === 'failed' ? '❌ Échec' : '💤 En attente'}
|
||||
</span>
|
||||
{a.current_project && (
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
Projet #{a.current_project}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-2 rounded-lg bg-surface-800/50">
|
||||
<div className="text-lg font-bold text-white">{a.total_executions}</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase">Total</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-surface-800/50">
|
||||
<div className="text-lg font-bold text-green-400">{a.success_count}</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase">Succès</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-surface-800/50">
|
||||
<div className="text-lg font-bold text-red-400">{a.failure_count}</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase">Échecs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success rate bar */}
|
||||
{a.total_executions > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-[10px] text-gray-500 mb-1">
|
||||
<span>Taux de succès</span>
|
||||
<span>{successRate}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-surface-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-500 to-green-400 rounded-full transition-all"
|
||||
style={{ width: `${successRate}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { ProjectSummary, AgentStatus, AuditLog } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../api/useWebSocket';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
COMPLETED: 'badge-completed',
|
||||
FAILED: 'badge-failed',
|
||||
PAUSED: 'badge-paused',
|
||||
};
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
if (status.endsWith('_RUNNING')) return 'badge-running';
|
||||
if (status.startsWith('AWAITING_')) return 'badge-awaiting';
|
||||
return STATUS_COLORS[status] || 'badge-awaiting';
|
||||
}
|
||||
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
COMPLETED: '✅', FAILED: '❌', PAUSED: '⏸️',
|
||||
};
|
||||
function getStatusIcon(s: string) {
|
||||
if (s.endsWith('_RUNNING')) return '⚡';
|
||||
if (s.startsWith('AWAITING_')) return '⏳';
|
||||
return STATUS_ICONS[s] || '❓';
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||
const [agents, setAgents] = useState<AgentStatus[]>([]);
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const [p, a, l] = await Promise.all([
|
||||
api.listProjects(),
|
||||
api.listAgents(),
|
||||
api.listLogs({ limit: '10' }),
|
||||
]);
|
||||
setProjects(p); setAgents(a); setLogs(l);
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||
|
||||
const { connected } = useWebSocket(useCallback((msg) => {
|
||||
if (msg.type === 'project_update' || msg.type === 'agent_status') fetchAll();
|
||||
}, [fetchAll]));
|
||||
|
||||
const active = projects.filter(p => !['COMPLETED', 'FAILED'].includes(p.status));
|
||||
const completed = projects.filter(p => p.status === 'COMPLETED');
|
||||
const runningAgents = agents.filter(a => a.current_status === 'running');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-fox-500 animate-spin-slow text-4xl">🦊</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Connection indicator */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : 'bg-red-400'}`}></span>
|
||||
{connected ? 'Connecté en temps réel' : 'Déconnecté — reconnexion...'}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon="📋" label="Projets actifs" value={active.length} accent="fox" />
|
||||
<StatCard icon="✅" label="Projets terminés" value={completed.length} accent="green" />
|
||||
<StatCard icon="🤖" label="Agents en cours" value={runningAgents.length} accent="blue" />
|
||||
<StatCard icon="📊" label="Total projets" value={projects.length} accent="purple" />
|
||||
</div>
|
||||
|
||||
{/* Active Projects */}
|
||||
<div className="glass-card p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span>⚡</span> Projets en cours
|
||||
</h2>
|
||||
{active.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Aucun projet actif</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{active.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between p-3 rounded-xl bg-surface-800/50 hover:bg-surface-700/50 transition-all">
|
||||
<div>
|
||||
<span className="text-white font-semibold text-sm">{p.name}</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`badge ${getStatusBadge(p.status)}`}>
|
||||
{getStatusIcon(p.status)} {p.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{p.workflow_type.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-400">{p.tasks_done}/{p.task_count} tâches</div>
|
||||
<div className="w-24 h-1.5 bg-surface-700 rounded-full mt-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-fox-600 to-fox-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${p.task_count > 0 ? (p.tasks_done / p.task_count) * 100 : 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents + Recent Logs */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Agent Grid */}
|
||||
<div className="glass-card p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span>🤖</span> Agents
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{agents.map((a) => (
|
||||
<div key={a.name} className="p-3 rounded-xl bg-surface-800/50 text-center">
|
||||
<div className="text-2xl mb-1">
|
||||
{a.current_status === 'running' ? '⚡' : a.current_status === 'failed' ? '❌' : '💤'}
|
||||
</div>
|
||||
<div className="text-xs text-white font-semibold">{a.display_name}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">{a.model}</div>
|
||||
<span className={`badge mt-1 text-[10px] ${
|
||||
a.current_status === 'running' ? 'badge-running' :
|
||||
a.current_status === 'failed' ? 'badge-failed' : 'badge-paused'
|
||||
}`}>
|
||||
{a.current_status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Logs */}
|
||||
<div className="glass-card p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span>📜</span> Activité récente
|
||||
</h2>
|
||||
<div className="space-y-2 max-h-72 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Aucune activité</p>
|
||||
) : logs.map((l) => (
|
||||
<div key={l.id} className="flex gap-3 p-2 rounded-lg hover:bg-surface-800/50 text-xs">
|
||||
<span className="text-fox-500 font-mono whitespace-nowrap">
|
||||
{new Date(l.timestamp).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span className="text-blue-400 font-semibold whitespace-nowrap">{l.agent}</span>
|
||||
<span className="text-gray-400 truncate">{l.message || l.action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, accent }: { icon: string; label: string; value: number; accent: string }) {
|
||||
const bgMap: Record<string, string> = {
|
||||
fox: 'from-fox-600/10 to-fox-700/5',
|
||||
green: 'from-green-500/10 to-green-600/5',
|
||||
blue: 'from-blue-500/10 to-blue-600/5',
|
||||
purple: 'from-purple-500/10 to-purple-600/5',
|
||||
};
|
||||
const textMap: Record<string, string> = {
|
||||
fox: 'text-fox-500', green: 'text-green-400', blue: 'text-blue-400', purple: 'text-purple-400',
|
||||
};
|
||||
return (
|
||||
<div className={`glass-card p-5 bg-gradient-to-br ${bgMap[accent]}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className={`text-3xl font-extrabold ${textMap[accent]}`}>{value}</div>
|
||||
</div>
|
||||
<div className="text-3xl opacity-60">{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import type { AuditLog } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../api/useWebSocket';
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
PROJECT_CREATED: 'text-green-400',
|
||||
WORKFLOW_STARTED: 'text-blue-400',
|
||||
WORKFLOW_PAUSED: 'text-yellow-400',
|
||||
WORKFLOW_STOPPED: 'text-red-400',
|
||||
WORKFLOW_RESET: 'text-purple-400',
|
||||
STATUS_CHANGED: 'text-fox-500',
|
||||
TASK_CREATED: 'text-teal-400',
|
||||
QA_APPROVED: 'text-green-400',
|
||||
QA_REJECTED: 'text-red-400',
|
||||
DEPLOYED: 'text-green-400',
|
||||
ROLLBACK: 'text-red-400',
|
||||
};
|
||||
|
||||
export default function LogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [agentFilter, setAgentFilter] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
const params: Record<string, string> = { limit: '200' };
|
||||
if (agentFilter) params.agent = agentFilter;
|
||||
try { setLogs(await api.listLogs(params)); } catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, [agentFilter]);
|
||||
|
||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||
|
||||
// Live updates
|
||||
const { connected } = useWebSocket(useCallback((msg) => {
|
||||
if (msg.type === 'log' || msg.type === 'project_update') fetchLogs();
|
||||
}, [fetchLogs]));
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && listRef.current) {
|
||||
listRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-64"><div className="text-fox-500 animate-spin-slow text-4xl">🦊</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
📜 Logs en temps réel
|
||||
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : 'bg-red-400'}`}></span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
className="input w-auto text-xs"
|
||||
value={agentFilter}
|
||||
onChange={(e) => setAgentFilter(e.target.value)}
|
||||
>
|
||||
<option value="">Tous les agents</option>
|
||||
<option value="system">Système</option>
|
||||
<option value="Foxy-Conductor">Conductor</option>
|
||||
<option value="Foxy-Architect">Architect</option>
|
||||
<option value="Foxy-Dev">Dev</option>
|
||||
<option value="Foxy-UIUX">UIUX</option>
|
||||
<option value="Foxy-QA">QA</option>
|
||||
<option value="Foxy-Admin">Admin</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} className="accent-fox-500" />
|
||||
Auto-scroll
|
||||
</label>
|
||||
<button className="btn btn-ghost text-xs" onClick={fetchLogs}>🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="glass-card p-4 max-h-[calc(100vh-200px)] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">Aucun log disponible</p>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-gray-500 text-left border-b border-surface-700">
|
||||
<th className="pb-2 pr-4">Heure</th>
|
||||
<th className="pb-2 pr-4">Agent</th>
|
||||
<th className="pb-2 pr-4">Action</th>
|
||||
<th className="pb-2 pr-4">Cible</th>
|
||||
<th className="pb-2">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((l) => (
|
||||
<tr key={l.id} className="border-b border-surface-800/50 hover:bg-surface-800/30">
|
||||
<td className="py-2 pr-4 text-gray-500 whitespace-nowrap">
|
||||
{new Date(l.timestamp).toLocaleString('fr-FR', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-blue-400 whitespace-nowrap">{l.agent}</td>
|
||||
<td className={`py-2 pr-4 whitespace-nowrap font-semibold ${ACTION_COLORS[l.action] || 'text-gray-400'}`}>
|
||||
{l.action}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-400 whitespace-nowrap">{l.target || '—'}</td>
|
||||
<td className="py-2 text-gray-300 truncate max-w-xs">{l.message || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ProjectSummary } 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';
|
||||
if (s === 'COMPLETED') return 'badge-completed';
|
||||
if (s === 'FAILED') return 'badge-failed';
|
||||
return 'badge-paused';
|
||||
}
|
||||
|
||||
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 [error, setError] = useState('');
|
||||
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
setProjects(await api.listProjects());
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { fetchProjects(); }, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.createProject(form);
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', description: '', workflow_type: 'SOFTWARE_DESIGN', test_mode: false });
|
||||
fetchProjects();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(id: number, action: 'start' | 'pause' | 'stop' | 'reset' | 'delete') {
|
||||
try {
|
||||
if (action === 'start') await api.startProject(id);
|
||||
else if (action === 'pause') await api.pauseProject(id);
|
||||
else if (action === 'stop') await api.stopProject(id);
|
||||
else if (action === 'reset') await api.resetProject(id);
|
||||
else if (action === 'delete') { await api.deleteProject(id); }
|
||||
fetchProjects();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Erreur');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-64"><div className="text-fox-500 animate-spin-slow text-4xl">🦊</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Projets</h1>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(!showCreate)}>
|
||||
{showCreate ? '✕ Fermer' : '+ Nouveau projet'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{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 />
|
||||
</div>
|
||||
<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>)}
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">Créer le projet</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Project List */}
|
||||
{projects.length === 0 ? (
|
||||
<div className="glass-card p-12 text-center text-gray-500">
|
||||
<div className="text-5xl mb-4">📭</div>
|
||||
<p>Aucun projet. Créez-en un pour commencer!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projects.map(p => (
|
||||
<div key={p.id} className="glass-card p-5">
|
||||
<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">
|
||||
<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>
|
||||
<span>🔄 {p.workflow_type.replace(/_/g, ' ')}</span>
|
||||
<span>📊 {p.tasks_done}/{p.task_count} tâches</span>
|
||||
<span>🕐 {new Date(p.updated_at).toLocaleString('fr-FR')}</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-1.5 bg-surface-700 rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-fox-600 to-fox-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${p.task_count > 0 ? (p.tasks_done / p.task_count) * 100 : 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!['COMPLETED', 'FAILED'].includes(p.status) && !p.status.endsWith('_RUNNING') && (
|
||||
<button className="btn btn-ghost text-xs" onClick={() => handleAction(p.id, 'start')}>▶ Start</button>
|
||||
)}
|
||||
{!['COMPLETED', 'FAILED', 'PAUSED'].includes(p.status) && (
|
||||
<button className="btn btn-ghost text-xs" onClick={() => handleAction(p.id, 'pause')}>⏸ Pause</button>
|
||||
)}
|
||||
<button className="btn btn-ghost text-xs" onClick={() => handleAction(p.id, 'reset')}>🔄 Reset</button>
|
||||
<button className="btn btn-ghost text-xs text-red-400" onClick={() => handleAction(p.id, 'stop')}>⏹ Stop</button>
|
||||
<button className="btn btn-ghost text-xs text-red-400" onClick={() => { if (confirm('Supprimer?')) handleAction(p.id, 'delete'); }}>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AppConfig } 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({
|
||||
OPENCLAW_WORKSPACE: config.OPENCLAW_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: 'OPENCLAW_WORKSPACE', label: 'Workspace OpenClaw', 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>
|
||||
|
||||
<form onSubmit={handleSave} className="glass-card p-6 space-y-4">
|
||||
{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>
|
||||
|
||||
{/* Workflows info */}
|
||||
<WorkflowsInfo />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowsInfo() {
|
||||
const [workflows, setWorkflows] = useState<{ type: string; label: 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-3">{wf.label}</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user