chore: install frontend dependencies.

This commit is contained in:
2026-03-12 17:00:42 -04:00
commit 18c8815166
63 changed files with 14100 additions and 0 deletions
+69
View File
@@ -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>
);
}
+168
View File
@@ -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'),
};
+64
View File
@@ -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 };
}
+227
View File
@@ -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);
}
+10
View File
@@ -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>,
)
+111
View File
@@ -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>
);
}
+185
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+120
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />