diff --git a/backend/app/models.py b/backend/app/models.py index 09e1554..f07da50 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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 diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index c8095f0..f7fba51 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -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"} diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 7da6764..be2a523 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -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 ────────────────────────────────────────────────────────── diff --git a/backend/app/routers/workflows.py b/backend/app/routers/workflows.py index cc19472..d7a406f 100644 --- a/backend/app/routers/workflows.py +++ b/backend/app/routers/workflows.py @@ -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 + diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1124b19..bbfb72f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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] = [] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5d170e4..3d84fbb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(path: string, options?: RequestInit): Promise { return res.json(); } -// ─── Projects ─────────────────────────────────────────────────────────────── +// ─── API ──────────────────────────────────────────────────────────────────── export const api = { // Projects @@ -136,7 +173,16 @@ export const api = { return request(`/api/projects${qs}`); }, getProject: (id: number) => request(`/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('/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('/api/projects/types'), + // Agents listAgents: () => request('/api/agents'), getAgentHistory: (name: string) => request(`/api/agents/${name}/history`), @@ -163,6 +212,18 @@ export const api = { updateConfig: (data: Record) => request<{ message: string }>('/api/config', { method: 'PUT', body: JSON.stringify(data) }), + // Deploy Servers + listDeployServers: () => request('/api/config/deploy-servers'), + createDeployServer: (data: { name: string; host: string; user?: string; password?: string; ssh_port?: number; description?: string }) => + request('/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('/api/config/git-servers'), + createGitServer: (data: { name: string; url: string; token?: string; org?: string; description?: string }) => + request('/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'), }; diff --git a/frontend/src/api/useWebSocket.ts b/frontend/src/api/useWebSocket.ts index 424e8d6..4961c07 100644 --- a/frontend/src/api/useWebSocket.ts +++ b/frontend/src/api/useWebSocket.ts @@ -14,7 +14,7 @@ type WSCallback = (msg: WSMessage) => void; export function useWebSocket(onMessage: WSCallback) { const wsRef = useRef(null); const [connected, setConnected] = useState(false); - const reconnectTimer = useRef>(); + const reconnectTimer = useRef | undefined>(undefined); const connect = useCallback(() => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; diff --git a/frontend/src/pages/Projects.tsx b/frontend/src/pages/Projects.tsx index 3e26e60..b308cbc 100644 --- a/frontend/src/pages/Projects.tsx +++ b/frontend/src/pages/Projects.tsx @@ -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([]); 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([]); + const [workflows, setWorkflows] = useState([]); + const [deployServers, setDeployServers] = useState([]); + const [gitServers, setGitServers] = useState([]); + 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
🦊
; } @@ -75,28 +119,120 @@ export default function ProjectsPage() { {/* Create form */} {showCreate && ( -
-

Nouveau projet

+ +

🦊 Nouveau projet

{error &&
{error}
} -
- - setForm({...form, name: e.target.value})} required /> + + {/* Row 1: Name + Description */} +
+
+ + setForm({...form, name: e.target.value})} required placeholder="mon-super-projet" /> +
+
+ + setForm({...form, description: e.target.value})} placeholder="Description courte du projet..." /> +
+ + {/* Row 2: Project Type */}
- -