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

This commit is contained in:
Bruno Charest 2026-03-13 08:54:45 -04:00
parent 18c8815166
commit a927534d16
9 changed files with 815 additions and 78 deletions

View File

@ -1,13 +1,10 @@
"""
SQLAlchemy ORM models for Foxy Dev Team.
"""
from __future__ import annotations
import enum
from datetime import datetime, timezone
from typing import Optional, List
from typing import List
from sqlalchemy import (
String, Text, Integer, DateTime, ForeignKey, Enum, JSON
String, Text, Integer, DateTime, ForeignKey, Enum, JSON, Boolean
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -43,6 +40,54 @@ class WorkflowType(str, enum.Enum):
SYSADMIN_ADJUST = "SYSADMIN_ADJUST"
class ProjectType(str, enum.Enum):
SYSTEMD_SERVICE = "SYSTEMD_SERVICE"
DOCKER_WEBAPP = "DOCKER_WEBAPP"
DOCKER_API = "DOCKER_API"
DOCKER_COMPOSE = "DOCKER_COMPOSE"
CLI_TOOL = "CLI_TOOL"
CRON_JOB = "CRON_JOB"
SHELL_SCRIPT = "SHELL_SCRIPT"
WORKER_MQ = "WORKER_MQ"
SOCKET_IPC = "SOCKET_IPC"
WEBHOOK_EVENT = "WEBHOOK_EVENT"
LIBRARY_PLUGIN = "LIBRARY_PLUGIN"
AGENT_BOT = "AGENT_BOT"
TUI_APP = "TUI_APP"
MCP_SERVER = "MCP_SERVER"
WASM_EDGE = "WASM_EDGE"
GRPC_SERVICE = "GRPC_SERVICE"
KERNEL_EBPF = "KERNEL_EBPF"
SERVERLESS_FAAS = "SERVERLESS_FAAS"
DESKTOP_GUI = "DESKTOP_GUI"
STATIC_SITE = "STATIC_SITE"
# Project type metadata (emoji, label, description)
PROJECT_TYPE_INFO = {
ProjectType.SYSTEMD_SERVICE: ("⚙️", "Service système (systemd)", "Démon long-running, démarrage automatique au boot, redémarrage sur erreur."),
ProjectType.DOCKER_WEBAPP: ("🌐", "Docker — Web app", "Application frontend ou backend HTTP/HTTPS containerisée."),
ProjectType.DOCKER_API: ("🔌", "Docker — API (REST / gRPC)", "Service HTTP headless containerisé, sans interface utilisateur."),
ProjectType.DOCKER_COMPOSE: ("🐳", "Docker Compose", "Orchestration de plusieurs conteneurs avec réseaux et volumes partagés."),
ProjectType.CLI_TOOL: ("💻", "Outil CLI", "Programme en ligne de commande avec sous-commandes, flags et stdin/stdout."),
ProjectType.CRON_JOB: ("", "Jobs planifiés (cron / timer)", "Exécution périodique ou batch, anacron pour machines non 24/7."),
ProjectType.SHELL_SCRIPT: ("📜", "Script shell (Bash / Zsh)", "Automatisation légère, glue entre outils, pipelines de commandes."),
ProjectType.WORKER_MQ: ("📨", "Worker + Message queue", "Traitement asynchrone via Redis, RabbitMQ ou Kafka."),
ProjectType.SOCKET_IPC: ("🔗", "Socket / IPC local", "Communication inter-processus via Unix socket, FIFO ou D-Bus."),
ProjectType.WEBHOOK_EVENT: ("🪝", "Webhook / Event-driven", "Réception d'événements HTTP entrants, architecture pub/sub."),
ProjectType.LIBRARY_PLUGIN: ("📦", "Bibliothèque / Plugin", "Code réutilisable sous forme de .so, package Python ou module Go."),
ProjectType.AGENT_BOT: ("🤖", "Agent / Bot autonome", "Service IA ou bot (Telegram, Discord) tournant en continu."),
ProjectType.TUI_APP: ("🖥️", "TUI (interface texte)", "Interface terminal interactive via curses, Textual ou Bubble Tea."),
ProjectType.MCP_SERVER: ("🧠", "Serveur MCP", "Exposition d'outils IA via Model Context Protocol (stdio ou HTTP SSE)."),
ProjectType.WASM_EDGE: ("🧊", "WebAssembly / Edge", "Modules wasm sandboxés, portables, via wasi et wasmtime."),
ProjectType.GRPC_SERVICE: ("", "Microservice gRPC / Thrift", "RPC binaire performant avec contrat IDL (protobuf)."),
ProjectType.KERNEL_EBPF: ("🔬", "Module kernel / eBPF", "Driver, hook réseau ou sonde d'observabilité au niveau noyau."),
ProjectType.SERVERLESS_FAAS: ("☁️", "Serverless / FaaS", "Fonctions déclenchées à la demande via OpenFaaS ou Knative."),
ProjectType.DESKTOP_GUI: ("🖼️", "Application desktop (GUI)", "Interface graphique native via GTK, Qt, ou Tauri/Electron."),
ProjectType.STATIC_SITE: ("📄", "Site statique / SSG", "Génération de HTML pur au build time via Hugo, Astro ou Jekyll."),
}
class TaskStatus(str, enum.Enum):
PENDING = "PENDING"
IN_PROGRESS = "IN_PROGRESS"
@ -78,7 +123,35 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Models ────────────────────────────────────────────────────────────────────
# ─── Infrastructure Models ─────────────────────────────────────────────────────
class DeployServer(Base):
__tablename__ = "deploy_servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
host: Mapped[str] = mapped_column(String(500), nullable=False)
user: Mapped[str] = mapped_column(String(100), default="deploy")
password: Mapped[str] = mapped_column(String(500), nullable=True)
ssh_port: Mapped[int] = mapped_column(Integer, default=22)
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
class GitServer(Base):
__tablename__ = "git_servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
url: Mapped[str] = mapped_column(String(500), nullable=False)
token: Mapped[str] = mapped_column(String(500), nullable=True)
org: Mapped[str] = mapped_column(String(200), default="openclaw")
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
# ─── Core Models ───────────────────────────────────────────────────────────────
class Project(Base):
@ -94,9 +167,15 @@ class Project(Base):
workflow_type: Mapped[WorkflowType] = mapped_column(
Enum(WorkflowType), default=WorkflowType.SOFTWARE_DESIGN, nullable=False
)
project_type: Mapped[ProjectType] = mapped_column(
Enum(ProjectType), default=ProjectType.DOCKER_WEBAPP, nullable=False
)
test_mode: Mapped[bool] = mapped_column(default=False)
gitea_repo: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
deployment_target: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
install_path: Mapped[str] = mapped_column(String(500), nullable=True)
gitea_repo: Mapped[str] = mapped_column(String(500), nullable=True)
deployment_target: Mapped[str] = mapped_column(String(200), nullable=True)
deploy_server_id: Mapped[int] = mapped_column(ForeignKey("deploy_servers.id"), nullable=True)
git_server_id: Mapped[int] = mapped_column(ForeignKey("git_servers.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
@ -112,6 +191,8 @@ class Project(Base):
agent_executions: Mapped[List["AgentExecution"]] = relationship(
back_populates="project", cascade="all, delete-orphan", lazy="selectin"
)
deploy_server: Mapped["DeployServer"] = relationship(lazy="selectin")
git_server: Mapped["GitServer"] = relationship(lazy="selectin")
class Task(Base):
@ -123,13 +204,13 @@ class Task(Base):
type: Mapped[TaskType] = mapped_column(Enum(TaskType), default=TaskType.BACKEND)
title: Mapped[str] = mapped_column(String(500), nullable=False)
priority: Mapped[TaskPriority] = mapped_column(Enum(TaskPriority), default=TaskPriority.P3)
assigned_to: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
assigned_to: Mapped[str] = mapped_column(String(100), nullable=True)
status: Mapped[TaskStatus] = mapped_column(
Enum(TaskStatus), default=TaskStatus.PENDING, nullable=False
)
dependencies: Mapped[Optional[dict]] = mapped_column(JSON, default=list)
acceptance_criteria: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
agent_payloads: Mapped[Optional[dict]] = mapped_column(JSON, default=dict)
dependencies: Mapped[dict] = mapped_column(JSON, default=list)
acceptance_criteria: Mapped[str] = mapped_column(Text, nullable=True)
agent_payloads: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
@ -148,11 +229,11 @@ class AgentExecution(Base):
status: Mapped[AgentExecutionStatus] = mapped_column(
Enum(AgentExecutionStatus), default=AgentExecutionStatus.RUNNING
)
pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
pid: Mapped[int] = mapped_column(Integer, nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
exit_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
error_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
exit_code: Mapped[int] = mapped_column(Integer, nullable=True)
error_output: Mapped[str] = mapped_column(Text, nullable=True)
# Relationships
project: Mapped["Project"] = relationship(back_populates="agent_executions")
@ -166,8 +247,8 @@ class AuditLog(Base):
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
agent: Mapped[str] = mapped_column(String(100), nullable=False)
action: Mapped[str] = mapped_column(String(100), nullable=False)
target: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
target: Mapped[str] = mapped_column(String(100), nullable=True)
message: Mapped[str] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(100), default="api")
# Relationships

View File

@ -1,17 +1,28 @@
"""
Configuration management API endpoint.
Configuration management and server CRUD API endpoints.
"""
import logging
from fastapi import APIRouter
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.schemas import ConfigResponse, ConfigUpdate
from app.database import get_db
from app.models import DeployServer, GitServer
from app.schemas import (
ConfigResponse, ConfigUpdate,
DeployServerCreate, DeployServerResponse,
GitServerCreate, GitServerResponse,
)
log = logging.getLogger("foxy.api.config")
router = APIRouter(prefix="/api/config", tags=["config"])
# ─── App Configuration ────────────────────────────────────────────────────────
@router.get("", response_model=ConfigResponse)
async def get_config():
"""Get current configuration (secrets masked)."""
@ -43,3 +54,86 @@ async def update_config(body: ConfigUpdate):
log.info(f"Config updated: {list(updated.keys())}")
return {"message": "Configuration updated", "updated_fields": list(updated.keys())}
# ─── Deploy Servers ────────────────────────────────────────────────────────────
@router.get("/deploy-servers", response_model=list[DeployServerResponse])
async def list_deploy_servers(db: AsyncSession = Depends(get_db)):
"""List all deployment servers."""
result = await db.execute(select(DeployServer).order_by(DeployServer.name))
return result.scalars().all()
@router.post("/deploy-servers", response_model=DeployServerResponse, status_code=201)
async def create_deploy_server(body: DeployServerCreate, db: AsyncSession = Depends(get_db)):
"""Add a new deployment server."""
server = DeployServer(
name=body.name,
host=body.host,
user=body.user,
password=body.password,
ssh_port=body.ssh_port,
description=body.description,
)
db.add(server)
await db.flush()
await db.refresh(server)
log.info(f"Deploy server created: {body.name} ({body.host})")
return server
@router.delete("/deploy-servers/{server_id}")
async def delete_deploy_server(server_id: int, db: AsyncSession = Depends(get_db)):
"""Remove a deployment server."""
result = await db.execute(select(DeployServer).where(DeployServer.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=404, detail="Deploy server not found")
name = server.name
await db.delete(server)
await db.flush()
log.info(f"Deploy server deleted: {name}")
return {"message": f"Deploy server '{name}' deleted"}
# ─── Git Servers ───────────────────────────────────────────────────────────────
@router.get("/git-servers", response_model=list[GitServerResponse])
async def list_git_servers(db: AsyncSession = Depends(get_db)):
"""List all Git servers."""
result = await db.execute(select(GitServer).order_by(GitServer.name))
return result.scalars().all()
@router.post("/git-servers", response_model=GitServerResponse, status_code=201)
async def create_git_server(body: GitServerCreate, db: AsyncSession = Depends(get_db)):
"""Add a new Git server."""
server = GitServer(
name=body.name,
url=body.url,
token=body.token,
org=body.org,
description=body.description,
)
db.add(server)
await db.flush()
await db.refresh(server)
log.info(f"Git server created: {body.name} ({body.url})")
return server
@router.delete("/git-servers/{server_id}")
async def delete_git_server(server_id: int, db: AsyncSession = Depends(get_db)):
"""Remove a Git server."""
result = await db.execute(select(GitServer).where(GitServer.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=404, detail="Git server not found")
name = server.name
await db.delete(server)
await db.flush()
log.info(f"Git server deleted: {name}")
return {"message": f"Git server '{name}' deleted"}

View File

@ -14,11 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import (
Project, Task, AuditLog, AgentExecution,
ProjectStatus, WorkflowType, AgentExecutionStatus,
ProjectStatus, WorkflowType, ProjectType, AgentExecutionStatus,
PROJECT_TYPE_INFO,
)
from app.schemas import (
ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail,
TaskCreate, TaskResponse,
TaskCreate, TaskResponse, ProjectTypeInfo,
)
from app.workflows import (
get_workflow_steps, get_current_step, get_workflow_progress,
@ -40,6 +41,23 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Project Types ─────────────────────────────────────────────────────────────
@router.get("/types", response_model=list[ProjectTypeInfo])
async def list_project_types():
"""Return all available project types with emoji, label and description."""
return [
ProjectTypeInfo(
value=pt.value,
emoji=info[0],
label=info[1],
description=info[2],
)
for pt, info in PROJECT_TYPE_INFO.items()
]
# ─── CRUD ──────────────────────────────────────────────────────────────────────
@ -54,7 +72,11 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
description=body.description,
status=ProjectStatus.AWAITING_CONDUCTOR,
workflow_type=body.workflow_type,
project_type=body.project_type,
test_mode=body.test_mode,
install_path=body.install_path,
deploy_server_id=body.deploy_server_id,
git_server_id=body.git_server_id,
)
db.add(project)
await db.flush()
@ -64,21 +86,48 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
agent="system",
action="PROJECT_CREATED",
target=slug,
message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value})",
message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value}, type: {body.project_type.value})",
source="api",
)
db.add(audit)
await db.flush()
# Refresh to load eager relationships (tasks, audit_logs, agent_executions)
await db.refresh(project, ["tasks", "audit_logs", "agent_executions"])
# Refresh to load eager relationships (tasks, audit_logs, agent_executions, servers)
await db.refresh(project, ["tasks", "audit_logs", "agent_executions", "deploy_server", "git_server"])
log.info(f"Project created: {slug} (workflow: {body.workflow_type.value})")
log.info(f"Project created: {slug} (workflow: {body.workflow_type.value}, type: {body.project_type.value})")
await manager.broadcast_project_update(project.id, project.status.value, project.name)
await notify_project_event(project.name, "Projet créé", f"Workflow: {body.workflow_type.value}")
await notify_project_event(project.name, "Projet créé", f"Workflow: {body.workflow_type.value}, Type: {body.project_type.value}")
return project
# Build response with server names
return _project_detail(project)
def _project_detail(p: Project) -> dict:
"""Build a ProjectDetail-compatible dict from a Project ORM instance."""
return {
"id": p.id,
"name": p.name,
"slug": p.slug,
"description": p.description,
"status": p.status,
"workflow_type": p.workflow_type,
"project_type": p.project_type,
"test_mode": p.test_mode,
"install_path": p.install_path,
"gitea_repo": p.gitea_repo,
"deployment_target": p.deployment_target,
"deploy_server_id": p.deploy_server_id,
"git_server_id": p.git_server_id,
"deploy_server_name": p.deploy_server.name if p.deploy_server else None,
"git_server_name": p.git_server.name if p.git_server else None,
"created_at": p.created_at,
"updated_at": p.updated_at,
"tasks": p.tasks,
"audit_logs": p.audit_logs,
"agent_executions": p.agent_executions,
}
@router.get("", response_model=list[ProjectSummary])
@ -115,9 +164,13 @@ async def list_projects(
slug=p.slug,
status=p.status,
workflow_type=p.workflow_type,
project_type=p.project_type,
test_mode=p.test_mode,
install_path=p.install_path,
task_count=total,
tasks_done=done,
deploy_server_name=p.deploy_server.name if p.deploy_server else None,
git_server_name=p.git_server.name if p.git_server else None,
created_at=p.created_at,
updated_at=p.updated_at,
))
@ -131,7 +184,7 @@ async def get_project(project_id: int, db: AsyncSession = Depends(get_db)):
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
return _project_detail(project)
# ─── Workflow Control ──────────────────────────────────────────────────────────

View File

@ -20,18 +20,23 @@ async def list_workflows():
"""List all available workflow types with their step sequences."""
workflows = []
for wf_type, steps in WORKFLOW_REGISTRY.items():
step_list = [
{
"agent": s.agent_name,
"label": AGENT_LABELS.get(s.agent_name, s.agent_name),
"model": AGENT_MODELS.get(s.agent_name, "unknown"),
"awaiting_status": s.awaiting_status,
"running_status": s.running_status,
}
for s in steps
]
# Build visual path string: "Conductor → Architect → Dev → QA → Admin"
path = "".join(s.agent_name for s in steps)
workflows.append({
"type": wf_type.value,
"label": wf_type.value.replace("_", " ").title(),
"steps": [
{
"agent": s.agent_name,
"label": AGENT_LABELS.get(s.agent_name, s.agent_name),
"model": AGENT_MODELS.get(s.agent_name, "unknown"),
"awaiting_status": s.awaiting_status,
"running_status": s.running_status,
}
for s in steps
],
"path": path,
"steps": step_list,
})
return workflows

View File

@ -7,7 +7,7 @@ from typing import Optional, List, Any
from pydantic import BaseModel, Field
from app.models import (
ProjectStatus, WorkflowType, TaskStatus, TaskType,
ProjectStatus, WorkflowType, ProjectType, TaskStatus, TaskType,
TaskPriority, AgentExecutionStatus,
)
@ -76,6 +76,62 @@ class AgentExecutionResponse(BaseModel):
model_config = {"from_attributes": True}
# ─── Deploy Server Schemas ─────────────────────────────────────────────────────
class DeployServerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
host: str = Field(..., min_length=1)
user: str = "deploy"
password: Optional[str] = None
ssh_port: int = 22
description: str = ""
class DeployServerResponse(BaseModel):
id: int
name: str
host: str
user: str
ssh_port: int
description: str
created_at: datetime
model_config = {"from_attributes": True}
# ─── Git Server Schemas ────────────────────────────────────────────────────────
class GitServerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
url: str = Field(..., min_length=1)
token: Optional[str] = None
org: str = "openclaw"
description: str = ""
class GitServerResponse(BaseModel):
id: int
name: str
url: str
org: str
description: str
created_at: datetime
model_config = {"from_attributes": True}
# ─── Project Type Info ─────────────────────────────────────────────────────────
class ProjectTypeInfo(BaseModel):
value: str
emoji: str
label: str
description: str
# ─── Project Schemas ───────────────────────────────────────────────────────────
@ -83,13 +139,21 @@ class ProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: str = ""
workflow_type: WorkflowType = WorkflowType.SOFTWARE_DESIGN
project_type: ProjectType = ProjectType.DOCKER_WEBAPP
test_mode: bool = False
install_path: Optional[str] = None
deploy_server_id: Optional[int] = None
git_server_id: Optional[int] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
workflow_type: Optional[WorkflowType] = None
project_type: Optional[ProjectType] = None
install_path: Optional[str] = None
deploy_server_id: Optional[int] = None
git_server_id: Optional[int] = None
class ProjectSummary(BaseModel):
@ -98,9 +162,13 @@ class ProjectSummary(BaseModel):
slug: str
status: ProjectStatus
workflow_type: WorkflowType
project_type: ProjectType
test_mode: bool
install_path: Optional[str]
task_count: int = 0
tasks_done: int = 0
deploy_server_name: Optional[str] = None
git_server_name: Optional[str] = None
created_at: datetime
updated_at: datetime
@ -114,9 +182,15 @@ class ProjectDetail(BaseModel):
description: str
status: ProjectStatus
workflow_type: WorkflowType
project_type: ProjectType
test_mode: bool
install_path: Optional[str]
gitea_repo: Optional[str]
deployment_target: Optional[str]
deploy_server_id: Optional[int]
git_server_id: Optional[int]
deploy_server_name: Optional[str] = None
git_server_name: Optional[str] = None
created_at: datetime
updated_at: datetime
tasks: List[TaskResponse] = []

View File

@ -13,9 +13,15 @@ export interface Project {
description: string;
status: string;
workflow_type: string;
project_type: string;
test_mode: boolean;
install_path?: string;
gitea_repo?: string;
deployment_target?: string;
deploy_server_id?: number;
git_server_id?: number;
deploy_server_name?: string;
git_server_name?: string;
created_at: string;
updated_at: string;
tasks: Task[];
@ -29,9 +35,13 @@ export interface ProjectSummary {
slug: string;
status: string;
workflow_type: string;
project_type: string;
test_mode: boolean;
install_path?: string;
task_count: number;
tasks_done: number;
deploy_server_name?: string;
git_server_name?: string;
created_at: string;
updated_at: string;
}
@ -89,6 +99,7 @@ export interface AgentStatus {
export interface WorkflowDef {
type: string;
label: string;
path: string;
steps: {
agent: string;
label: string;
@ -98,6 +109,32 @@ export interface WorkflowDef {
}[];
}
export interface ProjectTypeInfo {
value: string;
emoji: string;
label: string;
description: string;
}
export interface DeployServer {
id: number;
name: string;
host: string;
user: string;
ssh_port: number;
description: string;
created_at: string;
}
export interface GitServer {
id: number;
name: string;
url: string;
org: string;
description: string;
created_at: string;
}
export interface AppConfig {
OPENCLAW_WORKSPACE: string;
GITEA_SERVER: string;
@ -127,7 +164,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
return res.json();
}
// ─── Projects ───────────────────────────────────────────────────────────────
// ─── API ────────────────────────────────────────────────────────────────────
export const api = {
// Projects
@ -136,7 +173,16 @@ export const api = {
return request<ProjectSummary[]>(`/api/projects${qs}`);
},
getProject: (id: number) => request<Project>(`/api/projects/${id}`),
createProject: (data: { name: string; description: string; workflow_type: string; test_mode?: boolean }) =>
createProject: (data: {
name: string;
description: string;
workflow_type: string;
project_type: string;
test_mode?: boolean;
install_path?: string;
deploy_server_id?: number | null;
git_server_id?: number | null;
}) =>
request<Project>('/api/projects', { method: 'POST', body: JSON.stringify(data) }),
startProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/start`, { method: 'POST' }),
pauseProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/pause`, { method: 'POST' }),
@ -145,6 +191,9 @@ export const api = {
deleteProject: (id: number) => request<{ message: string }>(`/api/projects/${id}`, { method: 'DELETE' }),
getProgress: (id: number) => request<{ current_step: number; total_steps: number; percentage: number }>(`/api/projects/${id}/progress`),
// Project Types
listProjectTypes: () => request<ProjectTypeInfo[]>('/api/projects/types'),
// Agents
listAgents: () => request<AgentStatus[]>('/api/agents'),
getAgentHistory: (name: string) => request<AgentExecution[]>(`/api/agents/${name}/history`),
@ -163,6 +212,18 @@ export const api = {
updateConfig: (data: Record<string, string>) =>
request<{ message: string }>('/api/config', { method: 'PUT', body: JSON.stringify(data) }),
// Deploy Servers
listDeployServers: () => request<DeployServer[]>('/api/config/deploy-servers'),
createDeployServer: (data: { name: string; host: string; user?: string; password?: string; ssh_port?: number; description?: string }) =>
request<DeployServer>('/api/config/deploy-servers', { method: 'POST', body: JSON.stringify(data) }),
deleteDeployServer: (id: number) => request<{ message: string }>(`/api/config/deploy-servers/${id}`, { method: 'DELETE' }),
// Git Servers
listGitServers: () => request<GitServer[]>('/api/config/git-servers'),
createGitServer: (data: { name: string; url: string; token?: string; org?: string; description?: string }) =>
request<GitServer>('/api/config/git-servers', { method: 'POST', body: JSON.stringify(data) }),
deleteGitServer: (id: number) => request<{ message: string }>(`/api/config/git-servers/${id}`, { method: 'DELETE' }),
// Health
health: () => request<{ status: string; version: string }>('/api/health'),
};

View File

@ -14,7 +14,7 @@ type WSCallback = (msg: WSMessage) => void;
export function useWebSocket(onMessage: WSCallback) {
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';

View File

@ -1,14 +1,7 @@
import { useEffect, useState } from 'react';
import type { ProjectSummary } from '../api/client';
import type { ProjectSummary, ProjectTypeInfo, WorkflowDef, DeployServer, GitServer } from '../api/client';
import { api } from '../api/client';
const WORKFLOW_OPTIONS = [
{ value: 'SOFTWARE_DESIGN', label: '🏗️ Conception logicielle' },
{ value: 'SYSADMIN_DEBUG', label: '🐛 Débogage Sysadmin' },
{ value: 'DEVOPS_SETUP', label: '🐳 DevOps Setup' },
{ value: 'SYSADMIN_ADJUST', label: '🔧 Ajustement Sysadmin' },
];
function getStatusBadge(s: string) {
if (s.endsWith('_RUNNING')) return 'badge-running';
if (s.startsWith('AWAITING_')) return 'badge-awaiting';
@ -17,13 +10,30 @@ function getStatusBadge(s: string) {
return 'badge-paused';
}
const DEFAULT_FORM = {
name: '',
description: '',
workflow_type: 'SOFTWARE_DESIGN',
project_type: 'DOCKER_WEBAPP',
test_mode: false,
install_path: '',
deploy_server_id: null as number | null,
git_server_id: null as number | null,
};
export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ name: '', description: '', workflow_type: 'SOFTWARE_DESIGN', test_mode: false });
const [form, setForm] = useState({ ...DEFAULT_FORM });
const [error, setError] = useState('');
// Lookup data for dropdowns
const [projectTypes, setProjectTypes] = useState<ProjectTypeInfo[]>([]);
const [workflows, setWorkflows] = useState<WorkflowDef[]>([]);
const [deployServers, setDeployServers] = useState<DeployServer[]>([]);
const [gitServers, setGitServers] = useState<GitServer[]>([]);
async function fetchProjects() {
try {
setProjects(await api.listProjects());
@ -31,15 +41,41 @@ export default function ProjectsPage() {
setLoading(false);
}
useEffect(() => { fetchProjects(); }, []);
async function fetchDropdownData() {
try {
const [pt, wf, ds, gs] = await Promise.all([
api.listProjectTypes(),
api.listWorkflows(),
api.listDeployServers(),
api.listGitServers(),
]);
setProjectTypes(pt);
setWorkflows(wf);
setDeployServers(ds);
setGitServers(gs);
} catch { /* ignore */ }
}
useEffect(() => {
fetchProjects();
fetchDropdownData();
}, []);
const selectedWorkflow = workflows.find(w => w.type === form.workflow_type);
const selectedProjectType = projectTypes.find(pt => pt.value === form.project_type);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setError('');
try {
await api.createProject(form);
await api.createProject({
...form,
install_path: form.install_path || undefined,
deploy_server_id: form.deploy_server_id || undefined,
git_server_id: form.git_server_id || undefined,
});
setShowCreate(false);
setForm({ name: '', description: '', workflow_type: 'SOFTWARE_DESIGN', test_mode: false });
setForm({ ...DEFAULT_FORM });
fetchProjects();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur');
@ -59,6 +95,14 @@ export default function ProjectsPage() {
}
}
// Lookup emoji for a project type value
function typeEmoji(val: string) {
return projectTypes.find(pt => pt.value === val)?.emoji || '📦';
}
function typeLabel(val: string) {
return projectTypes.find(pt => pt.value === val)?.label || val;
}
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="text-fox-500 animate-spin-slow text-4xl">🦊</div></div>;
}
@ -75,28 +119,120 @@ export default function ProjectsPage() {
{/* Create form */}
{showCreate && (
<form onSubmit={handleCreate} className="glass-card p-6 space-y-4">
<h2 className="text-lg font-bold text-white">Nouveau projet</h2>
<form onSubmit={handleCreate} className="glass-card p-6 space-y-5">
<h2 className="text-lg font-bold text-white">🦊 Nouveau projet</h2>
{error && <div className="text-red-400 text-sm bg-red-400/10 p-3 rounded-lg">{error}</div>}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Nom</label>
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required />
{/* Row 1: Name + Description */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Nom du projet</label>
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required placeholder="mon-super-projet" />
</div>
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Description</label>
<input className="input" value={form.description} onChange={e => setForm({...form, description: e.target.value})} placeholder="Description courte du projet..." />
</div>
</div>
{/* Row 2: Project Type */}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Description</label>
<textarea className="input min-h-20" value={form.description} onChange={e => setForm({...form, description: e.target.value})} />
</div>
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Workflow</label>
<select className="input" value={form.workflow_type} onChange={e => setForm({...form, workflow_type: e.target.value})}>
{WORKFLOW_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🎯 Type de projet</label>
<select
className="input"
value={form.project_type}
onChange={e => setForm({...form, project_type: e.target.value})}
>
{projectTypes.map(pt => (
<option key={pt.value} value={pt.value}>{pt.emoji} {pt.label}</option>
))}
</select>
{/* Description of selected type */}
{selectedProjectType && (
<p className="text-xs text-gray-500 mt-1.5 ml-1">{selectedProjectType.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="test_mode" checked={form.test_mode} onChange={e => setForm({...form, test_mode: e.target.checked})} className="accent-fox-500" />
<label htmlFor="test_mode" className="text-sm text-gray-400">Mode test (simulation sans code réel)</label>
{/* Row 3: Workflow */}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🔄 Workflow</label>
<select
className="input"
value={form.workflow_type}
onChange={e => setForm({...form, workflow_type: e.target.value})}
>
{workflows.map(wf => (
<option key={wf.type} value={wf.type}>{wf.label}</option>
))}
</select>
{/* Path visualization */}
{selectedWorkflow && (
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{selectedWorkflow.steps.map((s, i) => (
<span key={i} className="flex items-center gap-1.5">
<span className="badge bg-fox-600/15 text-fox-400 text-[11px]">{s.agent}</span>
{i < selectedWorkflow.steps.length - 1 && <span className="text-gray-600 text-xs"></span>}
</span>
))}
</div>
)}
</div>
{/* Row 4: Install path */}
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">📁 Emplacement d'installation</label>
<input
className="input"
value={form.install_path}
onChange={e => setForm({...form, install_path: e.target.value})}
placeholder="/home/openclaw/projects/mon-projet"
/>
</div>
{/* Row 5: Servers */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🖥 Serveur de déploiement</label>
<select
className="input"
value={form.deploy_server_id ?? ''}
onChange={e => setForm({...form, deploy_server_id: e.target.value ? Number(e.target.value) : null})}
>
<option value=""> Aucun </option>
{deployServers.map(s => (
<option key={s.id} value={s.id}>{s.name} ({s.host})</option>
))}
</select>
{deployServers.length === 0 && (
<p className="text-xs text-gray-600 mt-1">Aucun serveur configuré. Ajoutez-en dans Config Serveurs.</p>
)}
</div>
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🌐 Serveur Git</label>
<select
className="input"
value={form.git_server_id ?? ''}
onChange={e => setForm({...form, git_server_id: e.target.value ? Number(e.target.value) : null})}
>
<option value=""> Aucun (pas de dépôt Git) </option>
{gitServers.map(s => (
<option key={s.id} value={s.id}>{s.name} ({s.url})</option>
))}
</select>
{gitServers.length === 0 && (
<p className="text-xs text-gray-600 mt-1">Aucun serveur Git configuré. Ajoutez-en dans Config Serveurs.</p>
)}
</div>
</div>
{/* Row 6: Test mode + Submit */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<input type="checkbox" id="test_mode" checked={form.test_mode} onChange={e => setForm({...form, test_mode: e.target.checked})} className="accent-fox-500" />
<label htmlFor="test_mode" className="text-sm text-gray-400">Mode test (simulation sans code réel)</label>
</div>
<button type="submit" className="btn btn-primary">🚀 Créer le projet</button>
</div>
<button type="submit" className="btn btn-primary">Créer le projet</button>
</form>
)}
@ -113,16 +249,20 @@ export default function ProjectsPage() {
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<span className="text-lg" title={typeLabel(p.project_type)}>{typeEmoji(p.project_type)}</span>
<h3 className="text-white font-semibold truncate">{p.name}</h3>
<span className={`badge ${getStatusBadge(p.status)}`}>
{p.status.replace(/_/g, ' ')}
</span>
{p.test_mode && <span className="badge bg-purple-500/15 text-purple-400">TEST</span>}
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>📁 {p.slug}</span>
<div className="flex items-center gap-4 text-xs text-gray-500 flex-wrap">
<span title={typeLabel(p.project_type)}>📦 {typeLabel(p.project_type)}</span>
<span>🔄 {p.workflow_type.replace(/_/g, ' ')}</span>
<span>📊 {p.tasks_done}/{p.task_count} tâches</span>
{p.deploy_server_name && <span>🖥 {p.deploy_server_name}</span>}
{p.git_server_name && <span>🌐 {p.git_server_name}</span>}
{p.install_path && <span>📁 {p.install_path}</span>}
<span>🕐 {new Date(p.updated_at).toLocaleString('fr-FR')}</span>
</div>
{/* Progress bar */}

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import type { AppConfig } from '../api/client';
import type { AppConfig, DeployServer, GitServer } from '../api/client';
import { api } from '../api/client';
export default function SettingsPage() {
@ -52,7 +52,9 @@ export default function SettingsPage() {
<div className="space-y-6 animate-fade-in">
<h1 className="text-xl font-bold text-white"> Configuration</h1>
{/* Global config */}
<form onSubmit={handleSave} className="glass-card p-6 space-y-4">
<h2 className="text-lg font-bold text-white">🔧 Paramètres généraux</h2>
{message && (
<div className={`text-sm p-3 rounded-lg ${message.startsWith('✅') ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{message}
@ -82,14 +84,240 @@ export default function SettingsPage() {
</div>
</form>
{/* Deploy Servers */}
<DeployServerManager />
{/* Git Servers */}
<GitServerManager />
{/* Workflows info */}
<WorkflowsInfo />
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Deploy Server Manager
// ═══════════════════════════════════════════════════════════════════════════════
function DeployServerManager() {
const [servers, setServers] = useState<DeployServer[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ name: '', host: '', user: 'deploy', password: '', ssh_port: 22, description: '' });
const [error, setError] = useState('');
async function fetchServers() {
try { setServers(await api.listDeployServers()); } catch { /* ignore */ }
}
useEffect(() => { fetchServers(); }, []);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setError('');
try {
await api.createDeployServer({
...form,
password: form.password || undefined,
description: form.description || undefined,
});
setForm({ name: '', host: '', user: 'deploy', password: '', ssh_port: 22, description: '' });
setShowAdd(false);
fetchServers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur');
}
}
async function handleDelete(id: number, name: string) {
if (!confirm(`Supprimer le serveur "${name}" ?`)) return;
try {
await api.deleteDeployServer(id);
fetchServers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Erreur');
}
}
return (
<div className="glass-card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-white">🖥 Serveurs de déploiement</h2>
<button className="btn btn-ghost text-xs" onClick={() => setShowAdd(!showAdd)}>
{showAdd ? '✕ Fermer' : ' Ajouter'}
</button>
</div>
{/* Add form */}
{showAdd && (
<form onSubmit={handleAdd} className="p-4 rounded-xl bg-surface-800/50 space-y-3">
{error && <div className="text-red-400 text-sm bg-red-400/10 p-2 rounded-lg">{error}</div>}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Nom</label>
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required placeholder="prod-server" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Hôte (IP / hostname)</label>
<input className="input" value={form.host} onChange={e => setForm({...form, host: e.target.value})} required placeholder="192.168.1.100" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Utilisateur SSH</label>
<input className="input" value={form.user} onChange={e => setForm({...form, user: e.target.value})} placeholder="deploy" />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Mot de passe SSH</label>
<input className="input" type="password" value={form.password} onChange={e => setForm({...form, password: e.target.value})} placeholder="(optionnel)" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Port SSH</label>
<input className="input" type="number" value={form.ssh_port} onChange={e => setForm({...form, ssh_port: Number(e.target.value)})} />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Description</label>
<input className="input" value={form.description} onChange={e => setForm({...form, description: e.target.value})} placeholder="Serveur production" />
</div>
</div>
<button type="submit" className="btn btn-primary text-sm">💾 Ajouter le serveur</button>
</form>
)}
{/* Server list */}
{servers.length === 0 ? (
<p className="text-gray-500 text-sm">Aucun serveur de déploiement configuré.</p>
) : (
<div className="space-y-2">
{servers.map(s => (
<div key={s.id} className="flex items-center justify-between p-3 rounded-xl bg-surface-800/50">
<div>
<span className="text-white font-semibold">{s.name}</span>
<span className="text-gray-500 text-sm ml-3">{s.user}@{s.host}:{s.ssh_port}</span>
{s.description && <span className="text-gray-600 text-xs ml-3"> {s.description}</span>}
</div>
<button className="text-red-400 hover:text-red-300 text-sm" onClick={() => handleDelete(s.id, s.name)}>🗑 Supprimer</button>
</div>
))}
</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Git Server Manager
// ═══════════════════════════════════════════════════════════════════════════════
function GitServerManager() {
const [servers, setServers] = useState<GitServer[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ name: '', url: '', token: '', org: 'openclaw', description: '' });
const [error, setError] = useState('');
async function fetchServers() {
try { setServers(await api.listGitServers()); } catch { /* ignore */ }
}
useEffect(() => { fetchServers(); }, []);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setError('');
try {
await api.createGitServer({
...form,
token: form.token || undefined,
description: form.description || undefined,
});
setForm({ name: '', url: '', token: '', org: 'openclaw', description: '' });
setShowAdd(false);
fetchServers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur');
}
}
async function handleDelete(id: number, name: string) {
if (!confirm(`Supprimer le serveur Git "${name}" ?`)) return;
try {
await api.deleteGitServer(id);
fetchServers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Erreur');
}
}
return (
<div className="glass-card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-white">🌐 Serveurs Git</h2>
<button className="btn btn-ghost text-xs" onClick={() => setShowAdd(!showAdd)}>
{showAdd ? '✕ Fermer' : ' Ajouter'}
</button>
</div>
{/* Add form */}
{showAdd && (
<form onSubmit={handleAdd} className="p-4 rounded-xl bg-surface-800/50 space-y-3">
{error && <div className="text-red-400 text-sm bg-red-400/10 p-2 rounded-lg">{error}</div>}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Nom</label>
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required placeholder="gitea-local" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">URL</label>
<input className="input" value={form.url} onChange={e => setForm({...form, url: e.target.value})} required placeholder="https://git.example.com" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Organisation</label>
<input className="input" value={form.org} onChange={e => setForm({...form, org: e.target.value})} placeholder="openclaw" />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Token API</label>
<input className="input" type="password" value={form.token} onChange={e => setForm({...form, token: e.target.value})} placeholder="(optionnel)" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Description</label>
<input className="input" value={form.description} onChange={e => setForm({...form, description: e.target.value})} placeholder="Gitea local" />
</div>
</div>
<button type="submit" className="btn btn-primary text-sm">💾 Ajouter le serveur Git</button>
</form>
)}
{/* Server list */}
{servers.length === 0 ? (
<p className="text-gray-500 text-sm">Aucun serveur Git configuré.</p>
) : (
<div className="space-y-2">
{servers.map(s => (
<div key={s.id} className="flex items-center justify-between p-3 rounded-xl bg-surface-800/50">
<div>
<span className="text-white font-semibold">{s.name}</span>
<span className="text-gray-500 text-sm ml-3">{s.url}</span>
<span className="text-gray-600 text-xs ml-2">(org: {s.org})</span>
{s.description && <span className="text-gray-600 text-xs ml-3"> {s.description}</span>}
</div>
<button className="text-red-400 hover:text-red-300 text-sm" onClick={() => handleDelete(s.id, s.name)}>🗑 Supprimer</button>
</div>
))}
</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Workflows Info
// ═══════════════════════════════════════════════════════════════════════════════
function WorkflowsInfo() {
const [workflows, setWorkflows] = useState<{ type: string; label: string; steps: { agent: string; model: string }[] }[]>([]);
const [workflows, setWorkflows] = useState<{ type: string; label: string; path: string; steps: { agent: string; model: string }[] }[]>([]);
useEffect(() => {
api.listWorkflows().then(setWorkflows).catch(() => {});
@ -103,7 +331,8 @@ function WorkflowsInfo() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{workflows.map(wf => (
<div key={wf.type} className="p-4 rounded-xl bg-surface-800/50">
<h3 className="text-white font-semibold mb-3">{wf.label}</h3>
<h3 className="text-white font-semibold mb-1">{wf.label}</h3>
<p className="text-xs text-gray-500 mb-3 font-mono">{wf.path}</p>
<div className="flex items-center gap-1 flex-wrap">
{wf.steps.map((s, i) => (
<div key={i} className="flex items-center gap-1">