feat: Implement comprehensive project management with new API client, backend routes, and frontend pages for projects and settings.
This commit is contained in:
parent
18c8815166
commit
a927534d16
@ -1,13 +1,10 @@
|
|||||||
"""
|
from __future__ import annotations
|
||||||
SQLAlchemy ORM models for Foxy Dev Team.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, List
|
from typing import List
|
||||||
|
|
||||||
from sqlalchemy import (
|
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
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@ -43,6 +40,54 @@ class WorkflowType(str, enum.Enum):
|
|||||||
SYSADMIN_ADJUST = "SYSADMIN_ADJUST"
|
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):
|
class TaskStatus(str, enum.Enum):
|
||||||
PENDING = "PENDING"
|
PENDING = "PENDING"
|
||||||
IN_PROGRESS = "IN_PROGRESS"
|
IN_PROGRESS = "IN_PROGRESS"
|
||||||
@ -78,7 +123,35 @@ def _utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
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):
|
class Project(Base):
|
||||||
@ -94,9 +167,15 @@ class Project(Base):
|
|||||||
workflow_type: Mapped[WorkflowType] = mapped_column(
|
workflow_type: Mapped[WorkflowType] = mapped_column(
|
||||||
Enum(WorkflowType), default=WorkflowType.SOFTWARE_DESIGN, nullable=False
|
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)
|
test_mode: Mapped[bool] = mapped_column(default=False)
|
||||||
gitea_repo: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
install_path: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||||
deployment_target: Mapped[Optional[str]] = mapped_column(String(200), 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||||
@ -112,6 +191,8 @@ class Project(Base):
|
|||||||
agent_executions: Mapped[List["AgentExecution"]] = relationship(
|
agent_executions: Mapped[List["AgentExecution"]] = relationship(
|
||||||
back_populates="project", cascade="all, delete-orphan", lazy="selectin"
|
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):
|
class Task(Base):
|
||||||
@ -123,13 +204,13 @@ class Task(Base):
|
|||||||
type: Mapped[TaskType] = mapped_column(Enum(TaskType), default=TaskType.BACKEND)
|
type: Mapped[TaskType] = mapped_column(Enum(TaskType), default=TaskType.BACKEND)
|
||||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
priority: Mapped[TaskPriority] = mapped_column(Enum(TaskPriority), default=TaskPriority.P3)
|
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(
|
status: Mapped[TaskStatus] = mapped_column(
|
||||||
Enum(TaskStatus), default=TaskStatus.PENDING, nullable=False
|
Enum(TaskStatus), default=TaskStatus.PENDING, nullable=False
|
||||||
)
|
)
|
||||||
dependencies: Mapped[Optional[dict]] = mapped_column(JSON, default=list)
|
dependencies: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
acceptance_criteria: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
acceptance_criteria: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
agent_payloads: Mapped[Optional[dict]] = mapped_column(JSON, default=dict)
|
agent_payloads: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||||
@ -148,11 +229,11 @@ class AgentExecution(Base):
|
|||||||
status: Mapped[AgentExecutionStatus] = mapped_column(
|
status: Mapped[AgentExecutionStatus] = mapped_column(
|
||||||
Enum(AgentExecutionStatus), default=AgentExecutionStatus.RUNNING
|
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)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
exit_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
exit_code: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
error_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
error_output: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
project: Mapped["Project"] = relationship(back_populates="agent_executions")
|
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)
|
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
action: 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)
|
target: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
source: Mapped[str] = mapped_column(String(100), default="api")
|
source: Mapped[str] = mapped_column(String(100), default="api")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@ -1,17 +1,28 @@
|
|||||||
"""
|
"""
|
||||||
Configuration management API endpoint.
|
Configuration management and server CRUD API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
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.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")
|
log = logging.getLogger("foxy.api.config")
|
||||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── App Configuration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ConfigResponse)
|
@router.get("", response_model=ConfigResponse)
|
||||||
async def get_config():
|
async def get_config():
|
||||||
"""Get current configuration (secrets masked)."""
|
"""Get current configuration (secrets masked)."""
|
||||||
@ -43,3 +54,86 @@ async def update_config(body: ConfigUpdate):
|
|||||||
|
|
||||||
log.info(f"Config updated: {list(updated.keys())}")
|
log.info(f"Config updated: {list(updated.keys())}")
|
||||||
return {"message": "Configuration updated", "updated_fields": 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"}
|
||||||
|
|||||||
@ -14,11 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Project, Task, AuditLog, AgentExecution,
|
Project, Task, AuditLog, AgentExecution,
|
||||||
ProjectStatus, WorkflowType, AgentExecutionStatus,
|
ProjectStatus, WorkflowType, ProjectType, AgentExecutionStatus,
|
||||||
|
PROJECT_TYPE_INFO,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail,
|
ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail,
|
||||||
TaskCreate, TaskResponse,
|
TaskCreate, TaskResponse, ProjectTypeInfo,
|
||||||
)
|
)
|
||||||
from app.workflows import (
|
from app.workflows import (
|
||||||
get_workflow_steps, get_current_step, get_workflow_progress,
|
get_workflow_steps, get_current_step, get_workflow_progress,
|
||||||
@ -40,6 +41,23 @@ def _utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
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 ──────────────────────────────────────────────────────────────────────
|
# ─── CRUD ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@ -54,7 +72,11 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
|
|||||||
description=body.description,
|
description=body.description,
|
||||||
status=ProjectStatus.AWAITING_CONDUCTOR,
|
status=ProjectStatus.AWAITING_CONDUCTOR,
|
||||||
workflow_type=body.workflow_type,
|
workflow_type=body.workflow_type,
|
||||||
|
project_type=body.project_type,
|
||||||
test_mode=body.test_mode,
|
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)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@ -64,21 +86,48 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
|
|||||||
agent="system",
|
agent="system",
|
||||||
action="PROJECT_CREATED",
|
action="PROJECT_CREATED",
|
||||||
target=slug,
|
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",
|
source="api",
|
||||||
)
|
)
|
||||||
db.add(audit)
|
db.add(audit)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Refresh to load eager relationships (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"])
|
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 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])
|
@router.get("", response_model=list[ProjectSummary])
|
||||||
@ -115,9 +164,13 @@ async def list_projects(
|
|||||||
slug=p.slug,
|
slug=p.slug,
|
||||||
status=p.status,
|
status=p.status,
|
||||||
workflow_type=p.workflow_type,
|
workflow_type=p.workflow_type,
|
||||||
|
project_type=p.project_type,
|
||||||
test_mode=p.test_mode,
|
test_mode=p.test_mode,
|
||||||
|
install_path=p.install_path,
|
||||||
task_count=total,
|
task_count=total,
|
||||||
tasks_done=done,
|
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,
|
created_at=p.created_at,
|
||||||
updated_at=p.updated_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()
|
project = result.scalar_one_or_none()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return project
|
return _project_detail(project)
|
||||||
|
|
||||||
|
|
||||||
# ─── Workflow Control ──────────────────────────────────────────────────────────
|
# ─── Workflow Control ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -20,18 +20,23 @@ async def list_workflows():
|
|||||||
"""List all available workflow types with their step sequences."""
|
"""List all available workflow types with their step sequences."""
|
||||||
workflows = []
|
workflows = []
|
||||||
for wf_type, steps in WORKFLOW_REGISTRY.items():
|
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({
|
workflows.append({
|
||||||
"type": wf_type.value,
|
"type": wf_type.value,
|
||||||
"label": wf_type.value.replace("_", " ").title(),
|
"label": wf_type.value.replace("_", " ").title(),
|
||||||
"steps": [
|
"path": path,
|
||||||
{
|
"steps": 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
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
return workflows
|
return workflows
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from typing import Optional, List, Any
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
ProjectStatus, WorkflowType, TaskStatus, TaskType,
|
ProjectStatus, WorkflowType, ProjectType, TaskStatus, TaskType,
|
||||||
TaskPriority, AgentExecutionStatus,
|
TaskPriority, AgentExecutionStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -76,6 +76,62 @@ class AgentExecutionResponse(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
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 ───────────────────────────────────────────────────────────
|
# ─── Project Schemas ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@ -83,13 +139,21 @@ class ProjectCreate(BaseModel):
|
|||||||
name: str = Field(..., min_length=1, max_length=200)
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
description: str = ""
|
description: str = ""
|
||||||
workflow_type: WorkflowType = WorkflowType.SOFTWARE_DESIGN
|
workflow_type: WorkflowType = WorkflowType.SOFTWARE_DESIGN
|
||||||
|
project_type: ProjectType = ProjectType.DOCKER_WEBAPP
|
||||||
test_mode: bool = False
|
test_mode: bool = False
|
||||||
|
install_path: Optional[str] = None
|
||||||
|
deploy_server_id: Optional[int] = None
|
||||||
|
git_server_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
workflow_type: Optional[WorkflowType] = 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):
|
class ProjectSummary(BaseModel):
|
||||||
@ -98,9 +162,13 @@ class ProjectSummary(BaseModel):
|
|||||||
slug: str
|
slug: str
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
workflow_type: WorkflowType
|
workflow_type: WorkflowType
|
||||||
|
project_type: ProjectType
|
||||||
test_mode: bool
|
test_mode: bool
|
||||||
|
install_path: Optional[str]
|
||||||
task_count: int = 0
|
task_count: int = 0
|
||||||
tasks_done: int = 0
|
tasks_done: int = 0
|
||||||
|
deploy_server_name: Optional[str] = None
|
||||||
|
git_server_name: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@ -114,9 +182,15 @@ class ProjectDetail(BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
workflow_type: WorkflowType
|
workflow_type: WorkflowType
|
||||||
|
project_type: ProjectType
|
||||||
test_mode: bool
|
test_mode: bool
|
||||||
|
install_path: Optional[str]
|
||||||
gitea_repo: Optional[str]
|
gitea_repo: Optional[str]
|
||||||
deployment_target: 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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
tasks: List[TaskResponse] = []
|
tasks: List[TaskResponse] = []
|
||||||
|
|||||||
@ -13,9 +13,15 @@ export interface Project {
|
|||||||
description: string;
|
description: string;
|
||||||
status: string;
|
status: string;
|
||||||
workflow_type: string;
|
workflow_type: string;
|
||||||
|
project_type: string;
|
||||||
test_mode: boolean;
|
test_mode: boolean;
|
||||||
|
install_path?: string;
|
||||||
gitea_repo?: string;
|
gitea_repo?: string;
|
||||||
deployment_target?: string;
|
deployment_target?: string;
|
||||||
|
deploy_server_id?: number;
|
||||||
|
git_server_id?: number;
|
||||||
|
deploy_server_name?: string;
|
||||||
|
git_server_name?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@ -29,9 +35,13 @@ export interface ProjectSummary {
|
|||||||
slug: string;
|
slug: string;
|
||||||
status: string;
|
status: string;
|
||||||
workflow_type: string;
|
workflow_type: string;
|
||||||
|
project_type: string;
|
||||||
test_mode: boolean;
|
test_mode: boolean;
|
||||||
|
install_path?: string;
|
||||||
task_count: number;
|
task_count: number;
|
||||||
tasks_done: number;
|
tasks_done: number;
|
||||||
|
deploy_server_name?: string;
|
||||||
|
git_server_name?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -89,6 +99,7 @@ export interface AgentStatus {
|
|||||||
export interface WorkflowDef {
|
export interface WorkflowDef {
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
path: string;
|
||||||
steps: {
|
steps: {
|
||||||
agent: string;
|
agent: string;
|
||||||
label: 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 {
|
export interface AppConfig {
|
||||||
OPENCLAW_WORKSPACE: string;
|
OPENCLAW_WORKSPACE: string;
|
||||||
GITEA_SERVER: string;
|
GITEA_SERVER: string;
|
||||||
@ -127,7 +164,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Projects ───────────────────────────────────────────────────────────────
|
// ─── API ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Projects
|
// Projects
|
||||||
@ -136,7 +173,16 @@ export const api = {
|
|||||||
return request<ProjectSummary[]>(`/api/projects${qs}`);
|
return request<ProjectSummary[]>(`/api/projects${qs}`);
|
||||||
},
|
},
|
||||||
getProject: (id: number) => request<Project>(`/api/projects/${id}`),
|
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) }),
|
request<Project>('/api/projects', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
startProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/start`, { method: 'POST' }),
|
startProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/start`, { method: 'POST' }),
|
||||||
pauseProject: (id: number) => request<{ status: string }>(`/api/projects/${id}/pause`, { 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' }),
|
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`),
|
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
|
// Agents
|
||||||
listAgents: () => request<AgentStatus[]>('/api/agents'),
|
listAgents: () => request<AgentStatus[]>('/api/agents'),
|
||||||
getAgentHistory: (name: string) => request<AgentExecution[]>(`/api/agents/${name}/history`),
|
getAgentHistory: (name: string) => request<AgentExecution[]>(`/api/agents/${name}/history`),
|
||||||
@ -163,6 +212,18 @@ export const api = {
|
|||||||
updateConfig: (data: Record<string, string>) =>
|
updateConfig: (data: Record<string, string>) =>
|
||||||
request<{ message: string }>('/api/config', { method: 'PUT', body: JSON.stringify(data) }),
|
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
|
||||||
health: () => request<{ status: string; version: string }>('/api/health'),
|
health: () => request<{ status: string; version: string }>('/api/health'),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,7 +14,7 @@ type WSCallback = (msg: WSMessage) => void;
|
|||||||
export function useWebSocket(onMessage: WSCallback) {
|
export function useWebSocket(onMessage: WSCallback) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
|||||||
@ -1,14 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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';
|
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) {
|
function getStatusBadge(s: string) {
|
||||||
if (s.endsWith('_RUNNING')) return 'badge-running';
|
if (s.endsWith('_RUNNING')) return 'badge-running';
|
||||||
if (s.startsWith('AWAITING_')) return 'badge-awaiting';
|
if (s.startsWith('AWAITING_')) return 'badge-awaiting';
|
||||||
@ -17,13 +10,30 @@ function getStatusBadge(s: string) {
|
|||||||
return 'badge-paused';
|
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() {
|
export default function ProjectsPage() {
|
||||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
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('');
|
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() {
|
async function fetchProjects() {
|
||||||
try {
|
try {
|
||||||
setProjects(await api.listProjects());
|
setProjects(await api.listProjects());
|
||||||
@ -31,15 +41,41 @@ export default function ProjectsPage() {
|
|||||||
setLoading(false);
|
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) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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);
|
setShowCreate(false);
|
||||||
setForm({ name: '', description: '', workflow_type: 'SOFTWARE_DESIGN', test_mode: false });
|
setForm({ ...DEFAULT_FORM });
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Erreur');
|
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) {
|
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="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 */}
|
{/* Create form */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<form onSubmit={handleCreate} className="glass-card p-6 space-y-4">
|
<form onSubmit={handleCreate} className="glass-card p-6 space-y-5">
|
||||||
<h2 className="text-lg font-bold text-white">Nouveau projet</h2>
|
<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>}
|
{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>
|
{/* Row 1: Name + Description */}
|
||||||
<input className="input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Project Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Description</label>
|
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">🎯 Type de projet</label>
|
||||||
<textarea className="input min-h-20" value={form.description} onChange={e => setForm({...form, description: e.target.value})} />
|
<select
|
||||||
</div>
|
className="input"
|
||||||
<div>
|
value={form.project_type}
|
||||||
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">Workflow</label>
|
onChange={e => setForm({...form, project_type: e.target.value})}
|
||||||
<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>)}
|
{projectTypes.map(pt => (
|
||||||
|
<option key={pt.value} value={pt.value}>{pt.emoji} {pt.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{/* Description of selected type */}
|
||||||
|
{selectedProjectType && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1.5 ml-1">{selectedProjectType.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</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" />
|
{/* Row 3: Workflow */}
|
||||||
<label htmlFor="test_mode" className="text-sm text-gray-400">Mode test (simulation sans code réel)</label>
|
<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>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary">Créer le projet</button>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -113,16 +249,20 @@ export default function ProjectsPage() {
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<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>
|
<h3 className="text-white font-semibold truncate">{p.name}</h3>
|
||||||
<span className={`badge ${getStatusBadge(p.status)}`}>
|
<span className={`badge ${getStatusBadge(p.status)}`}>
|
||||||
{p.status.replace(/_/g, ' ')}
|
{p.status.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
{p.test_mode && <span className="badge bg-purple-500/15 text-purple-400">TEST</span>}
|
{p.test_mode && <span className="badge bg-purple-500/15 text-purple-400">TEST</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
<div className="flex items-center gap-4 text-xs text-gray-500 flex-wrap">
|
||||||
<span>📁 {p.slug}</span>
|
<span title={typeLabel(p.project_type)}>📦 {typeLabel(p.project_type)}</span>
|
||||||
<span>🔄 {p.workflow_type.replace(/_/g, ' ')}</span>
|
<span>🔄 {p.workflow_type.replace(/_/g, ' ')}</span>
|
||||||
<span>📊 {p.tasks_done}/{p.task_count} tâches</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>
|
<span>🕐 {new Date(p.updated_at).toLocaleString('fr-FR')}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { AppConfig } from '../api/client';
|
import type { AppConfig, DeployServer, GitServer } from '../api/client';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@ -52,7 +52,9 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<h1 className="text-xl font-bold text-white">⚙️ Configuration</h1>
|
<h1 className="text-xl font-bold text-white">⚙️ Configuration</h1>
|
||||||
|
|
||||||
|
{/* Global config */}
|
||||||
<form onSubmit={handleSave} className="glass-card p-6 space-y-4">
|
<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 && (
|
{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'}`}>
|
<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}
|
{message}
|
||||||
@ -82,14 +84,240 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Deploy Servers */}
|
||||||
|
<DeployServerManager />
|
||||||
|
|
||||||
|
{/* Git Servers */}
|
||||||
|
<GitServerManager />
|
||||||
|
|
||||||
{/* Workflows info */}
|
{/* Workflows info */}
|
||||||
<WorkflowsInfo />
|
<WorkflowsInfo />
|
||||||
</div>
|
</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() {
|
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(() => {
|
useEffect(() => {
|
||||||
api.listWorkflows().then(setWorkflows).catch(() => {});
|
api.listWorkflows().then(setWorkflows).catch(() => {});
|
||||||
@ -103,7 +331,8 @@ function WorkflowsInfo() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{workflows.map(wf => (
|
{workflows.map(wf => (
|
||||||
<div key={wf.type} className="p-4 rounded-xl bg-surface-800/50">
|
<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">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{wf.steps.map((s, i) => (
|
{wf.steps.map((s, i) => (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user