feat(delivery): the Application page + owner consoles (v2 phase A3-6)

The user-visible payoff of the whole delivery workstream.

- (app)/app: four tabs — pipeline (runs + the eight steps + a live console),
  releases (with demo rollback), production (target summary, confirmed deploy,
  history), Git. The header answers "is it live": demo URL, live release,
  health state.
- Live console: batches onto an animation frame, stops following the tail the
  moment the user scrolls away from the bottom, strips ANSI colour codes and
  carriage-return redraws. A console that yanks the view back is worse than none.
- Production panel: the target is shown as the operator recognises their server
  plus has_secret/ثبت شده. No field anywhere could render a credential.
- contracts: the v2 delivery types and seven new WsEventType members.
- /admin/sandbox (owner): per-role container counts, running containers, per-org
  limits, image mapping, stop with the id typed back (audited). A provider without
  the capability reports an empty list rather than an error.
- /admin/domains (owner): every route with its owning app, attach/detach (host
  typed back), idempotent re-sync.
- /projects removed entirely, with a permanent redirect to /app.

Tests: 502 API (+3), 78 web (+11); ruff, mypy, tsc and lint clean.

Report: docs/plan/reports/agent-3-phase-6.md
This commit is contained in:
mjalmoudy94
2026-10-14 02:03:38 +03:30
parent 72408a0a4f
commit 3c8296e354
15 changed files with 2462 additions and 659 deletions
+134 -1
View File
@@ -23,23 +23,33 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.sandbox_service import SandboxService
from app.core.database import get_session
from app.core.errors import PermissionDeniedError
from app.core.errors import ConflictError, PermissionDeniedError, ValidationError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import User
from app.domain.sandbox.models import NetworkPolicy
from app.infrastructure.adapters.docker_sandbox import LANGUAGE_IMAGES
from app.infrastructure.db.models import SandboxResourceLimitRow
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_sandbox_service,
require_platform_owner,
)
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
#: The platform owner's view of every container on the host. Mounted at
#: ``/api/admin/sandbox`` — a *separate* router object, because the tenant router
#: above is org-scoped and this one must never be.
admin_router = APIRouter(prefix="/admin/sandbox", tags=["admin-sandbox"])
def _require_platform_owner(context: OrgContext) -> None:
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
@@ -469,3 +479,126 @@ async def get_preview_url(
organization_id=context.org_id, session_id=session_id, port=port
)
return PreviewUrlOut(port=preview.port, url=preview.url)
# ---------------------------------------------------------------------------
# Platform-owner sandbox console (/admin/sandbox)
# ---------------------------------------------------------------------------
class ContainerRow(BaseModel):
id: str
name: str
status: str
organization_id: str
project_id: str
role: str
class SandboxListOut(BaseModel):
containers: list[ContainerRow]
limits: list[SandboxLimitRow]
images: dict[str, str]
class SandboxLimitRow(BaseModel):
organization_id: str
cpu: float
memory_mb: int
disk_mb: int
timeout_seconds: int
network_policy: str
class StopContainerRequest(BaseModel):
#: The container id, typed back — stopping one is destructive.
confirm: str = Field(min_length=1, max_length=200)
@admin_router.get("/containers", response_model=SandboxListOut)
async def list_containers(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> SandboxListOut:
"""Every container carrying our labels, plus the per-org limits.
A provider that cannot list containers (the mock, a remote adapter without
the capability) returns an empty list rather than an error: the console is
diagnostic, and a provider outage should not look like "no containers".
"""
from app.infrastructure.adapters.registry import get_sandbox_provider
provider = get_sandbox_provider()
lister = getattr(provider, "list_managed_containers", None)
containers: list[ContainerRow] = []
if lister is not None:
try:
for item in await lister():
containers.append(
ContainerRow(
id=item.container_id,
name=item.name,
status=item.status,
organization_id=item.organization_id,
project_id=item.project_id,
role=item.role,
)
)
except Exception: # noqa: BLE001 - a CLI outage is not a 500
containers = []
limits = (
await session.execute(
select(SandboxResourceLimitRow).where(
SandboxResourceLimitRow.scope == "organization"
)
)
).scalars()
return SandboxListOut(
containers=containers,
limits=[
SandboxLimitRow(
organization_id=row.scope_id or "",
cpu=row.cpu,
memory_mb=row.memory_mb,
disk_mb=row.disk_mb,
timeout_seconds=row.timeout_seconds,
network_policy=str(row.network_policy),
)
for row in limits
if row.scope_id
],
images=dict(LANGUAGE_IMAGES),
)
@admin_router.post("/containers/{container_id}/stop", status_code=status.HTTP_204_NO_CONTENT)
async def stop_container(
container_id: str,
payload: StopContainerRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> None:
"""Stop and remove a container. Owner-only, confirmed and audited."""
if payload.confirm.strip() != container_id.strip():
raise ValidationError("تأییدیه با شناسهٔ کانتینر مطابقت ندارد.")
from app.infrastructure.adapters.registry import get_sandbox_provider
provider = get_sandbox_provider()
remover = getattr(provider, "remove_container", None)
if remover is None:
raise ValidationError("این ارائه‌دهندهٔ سندباکس توقف کانتینر را پشتیبانی نمی‌کند.")
removed = await remover(container_id)
if not removed:
raise ConflictError("توقف کانتینر ناموفق بود.")
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.SANDBOX_DESTROY, actor_user_id=owner.id,
target_type="sandbox_container", target_id=container_id, ip=ip, user_agent=ua,
)