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

This commit is contained in:
2026-03-13 08:54:45 -04:00
parent 18c8815166
commit a927534d16
9 changed files with 815 additions and 78 deletions
+97 -3
View File
@@ -1,17 +1,28 @@
"""
Configuration management API endpoint.
Configuration management and server CRUD API endpoints.
"""
import logging
from fastapi import APIRouter
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.schemas import ConfigResponse, ConfigUpdate
from app.database import get_db
from app.models import DeployServer, GitServer
from app.schemas import (
ConfigResponse, ConfigUpdate,
DeployServerCreate, DeployServerResponse,
GitServerCreate, GitServerResponse,
)
log = logging.getLogger("foxy.api.config")
router = APIRouter(prefix="/api/config", tags=["config"])
# ─── App Configuration ────────────────────────────────────────────────────────
@router.get("", response_model=ConfigResponse)
async def get_config():
"""Get current configuration (secrets masked)."""
@@ -43,3 +54,86 @@ async def update_config(body: ConfigUpdate):
log.info(f"Config updated: {list(updated.keys())}")
return {"message": "Configuration updated", "updated_fields": list(updated.keys())}
# ─── Deploy Servers ────────────────────────────────────────────────────────────
@router.get("/deploy-servers", response_model=list[DeployServerResponse])
async def list_deploy_servers(db: AsyncSession = Depends(get_db)):
"""List all deployment servers."""
result = await db.execute(select(DeployServer).order_by(DeployServer.name))
return result.scalars().all()
@router.post("/deploy-servers", response_model=DeployServerResponse, status_code=201)
async def create_deploy_server(body: DeployServerCreate, db: AsyncSession = Depends(get_db)):
"""Add a new deployment server."""
server = DeployServer(
name=body.name,
host=body.host,
user=body.user,
password=body.password,
ssh_port=body.ssh_port,
description=body.description,
)
db.add(server)
await db.flush()
await db.refresh(server)
log.info(f"Deploy server created: {body.name} ({body.host})")
return server
@router.delete("/deploy-servers/{server_id}")
async def delete_deploy_server(server_id: int, db: AsyncSession = Depends(get_db)):
"""Remove a deployment server."""
result = await db.execute(select(DeployServer).where(DeployServer.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=404, detail="Deploy server not found")
name = server.name
await db.delete(server)
await db.flush()
log.info(f"Deploy server deleted: {name}")
return {"message": f"Deploy server '{name}' deleted"}
# ─── Git Servers ───────────────────────────────────────────────────────────────
@router.get("/git-servers", response_model=list[GitServerResponse])
async def list_git_servers(db: AsyncSession = Depends(get_db)):
"""List all Git servers."""
result = await db.execute(select(GitServer).order_by(GitServer.name))
return result.scalars().all()
@router.post("/git-servers", response_model=GitServerResponse, status_code=201)
async def create_git_server(body: GitServerCreate, db: AsyncSession = Depends(get_db)):
"""Add a new Git server."""
server = GitServer(
name=body.name,
url=body.url,
token=body.token,
org=body.org,
description=body.description,
)
db.add(server)
await db.flush()
await db.refresh(server)
log.info(f"Git server created: {body.name} ({body.url})")
return server
@router.delete("/git-servers/{server_id}")
async def delete_git_server(server_id: int, db: AsyncSession = Depends(get_db)):
"""Remove a Git server."""
result = await db.execute(select(GitServer).where(GitServer.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=404, detail="Git server not found")
name = server.name
await db.delete(server)
await db.flush()
log.info(f"Git server deleted: {name}")
return {"message": f"Git server '{name}' deleted"}
+62 -9
View File
@@ -14,11 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import (
Project, Task, AuditLog, AgentExecution,
ProjectStatus, WorkflowType, AgentExecutionStatus,
ProjectStatus, WorkflowType, ProjectType, AgentExecutionStatus,
PROJECT_TYPE_INFO,
)
from app.schemas import (
ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail,
TaskCreate, TaskResponse,
TaskCreate, TaskResponse, ProjectTypeInfo,
)
from app.workflows import (
get_workflow_steps, get_current_step, get_workflow_progress,
@@ -40,6 +41,23 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Project Types ─────────────────────────────────────────────────────────────
@router.get("/types", response_model=list[ProjectTypeInfo])
async def list_project_types():
"""Return all available project types with emoji, label and description."""
return [
ProjectTypeInfo(
value=pt.value,
emoji=info[0],
label=info[1],
description=info[2],
)
for pt, info in PROJECT_TYPE_INFO.items()
]
# ─── CRUD ──────────────────────────────────────────────────────────────────────
@@ -54,7 +72,11 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
description=body.description,
status=ProjectStatus.AWAITING_CONDUCTOR,
workflow_type=body.workflow_type,
project_type=body.project_type,
test_mode=body.test_mode,
install_path=body.install_path,
deploy_server_id=body.deploy_server_id,
git_server_id=body.git_server_id,
)
db.add(project)
await db.flush()
@@ -64,21 +86,48 @@ async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)
agent="system",
action="PROJECT_CREATED",
target=slug,
message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value})",
message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value}, type: {body.project_type.value})",
source="api",
)
db.add(audit)
await db.flush()
# Refresh to load eager relationships (tasks, audit_logs, agent_executions)
await db.refresh(project, ["tasks", "audit_logs", "agent_executions"])
# Refresh to load eager relationships (tasks, audit_logs, agent_executions, servers)
await db.refresh(project, ["tasks", "audit_logs", "agent_executions", "deploy_server", "git_server"])
log.info(f"Project created: {slug} (workflow: {body.workflow_type.value})")
log.info(f"Project created: {slug} (workflow: {body.workflow_type.value}, type: {body.project_type.value})")
await manager.broadcast_project_update(project.id, project.status.value, project.name)
await notify_project_event(project.name, "Projet créé", f"Workflow: {body.workflow_type.value}")
await notify_project_event(project.name, "Projet créé", f"Workflow: {body.workflow_type.value}, Type: {body.project_type.value}")
return project
# Build response with server names
return _project_detail(project)
def _project_detail(p: Project) -> dict:
"""Build a ProjectDetail-compatible dict from a Project ORM instance."""
return {
"id": p.id,
"name": p.name,
"slug": p.slug,
"description": p.description,
"status": p.status,
"workflow_type": p.workflow_type,
"project_type": p.project_type,
"test_mode": p.test_mode,
"install_path": p.install_path,
"gitea_repo": p.gitea_repo,
"deployment_target": p.deployment_target,
"deploy_server_id": p.deploy_server_id,
"git_server_id": p.git_server_id,
"deploy_server_name": p.deploy_server.name if p.deploy_server else None,
"git_server_name": p.git_server.name if p.git_server else None,
"created_at": p.created_at,
"updated_at": p.updated_at,
"tasks": p.tasks,
"audit_logs": p.audit_logs,
"agent_executions": p.agent_executions,
}
@router.get("", response_model=list[ProjectSummary])
@@ -115,9 +164,13 @@ async def list_projects(
slug=p.slug,
status=p.status,
workflow_type=p.workflow_type,
project_type=p.project_type,
test_mode=p.test_mode,
install_path=p.install_path,
task_count=total,
tasks_done=done,
deploy_server_name=p.deploy_server.name if p.deploy_server else None,
git_server_name=p.git_server.name if p.git_server else None,
created_at=p.created_at,
updated_at=p.updated_at,
))
@@ -131,7 +184,7 @@ async def get_project(project_id: int, db: AsyncSession = Depends(get_db)):
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
return _project_detail(project)
# ─── Workflow Control ──────────────────────────────────────────────────────────
+15 -10
View File
@@ -20,18 +20,23 @@ async def list_workflows():
"""List all available workflow types with their step sequences."""
workflows = []
for wf_type, steps in WORKFLOW_REGISTRY.items():
step_list = [
{
"agent": s.agent_name,
"label": AGENT_LABELS.get(s.agent_name, s.agent_name),
"model": AGENT_MODELS.get(s.agent_name, "unknown"),
"awaiting_status": s.awaiting_status,
"running_status": s.running_status,
}
for s in steps
]
# Build visual path string: "Conductor → Architect → Dev → QA → Admin"
path = " → ".join(s.agent_name for s in steps)
workflows.append({
"type": wf_type.value,
"label": wf_type.value.replace("_", " ").title(),
"steps": [
{
"agent": s.agent_name,
"label": AGENT_LABELS.get(s.agent_name, s.agent_name),
"model": AGENT_MODELS.get(s.agent_name, "unknown"),
"awaiting_status": s.awaiting_status,
"running_status": s.running_status,
}
for s in steps
],
"path": path,
"steps": step_list,
})
return workflows