From 3c8296e3547708b8138f2806e354bc1fb515960f Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Wed, 14 Oct 2026 02:03:38 +0330 Subject: [PATCH] feat(delivery): the Application page + owner consoles (v2 phase A3-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/interfaces/http/routers/sandbox.py | 135 ++++- apps/api/app/interfaces/realtime/rpc_app.py | 1 + apps/api/tests/test_docker_sandbox.py | 67 ++- apps/web/next.config.mjs | 9 + apps/web/src/app/(app)/admin/domains/page.tsx | 330 +++++++++++- apps/web/src/app/(app)/admin/sandbox/page.tsx | 247 ++++++++- .../app/_components/pipeline-console.tsx | 141 ++++++ .../app/_components/production-panel.tsx | 275 ++++++++++ apps/web/src/app/(app)/app/page.tsx | 470 +++++++++++++++++- apps/web/src/app/(app)/projects/[id]/page.tsx | 468 ----------------- apps/web/src/app/(app)/projects/page.tsx | 150 ------ apps/web/src/lib/api-delivery.ts | 117 ++++- apps/web/src/test/delivery-app.test.tsx | 382 ++++++++++++++ docs/plan/reports/agent-3-phase-6.md | 128 +++++ packages/contracts/src/index.ts | 201 +++++++- 15 files changed, 2462 insertions(+), 659 deletions(-) create mode 100644 apps/web/src/app/(app)/app/_components/pipeline-console.tsx create mode 100644 apps/web/src/app/(app)/app/_components/production-panel.tsx delete mode 100644 apps/web/src/app/(app)/projects/[id]/page.tsx delete mode 100644 apps/web/src/app/(app)/projects/page.tsx create mode 100644 apps/web/src/test/delivery-app.test.tsx create mode 100644 docs/plan/reports/agent-3-phase-6.md diff --git a/apps/api/app/interfaces/http/routers/sandbox.py b/apps/api/app/interfaces/http/routers/sandbox.py index d8cacc7..f570d51 100644 --- a/apps/api/app/interfaces/http/routers/sandbox.py +++ b/apps/api/app/interfaces/http/routers/sandbox.py @@ -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, + ) diff --git a/apps/api/app/interfaces/realtime/rpc_app.py b/apps/api/app/interfaces/realtime/rpc_app.py index 0c233e4..cc9bf0d 100644 --- a/apps/api/app/interfaces/realtime/rpc_app.py +++ b/apps/api/app/interfaces/realtime/rpc_app.py @@ -84,6 +84,7 @@ def build_rpc_app() -> FastAPI: application.include_router(admin_grants.router, prefix="/api") application.include_router(delivery.router, prefix="/api") application.include_router(admin_domains.router, prefix="/api") + application.include_router(sandbox.admin_router, prefix="/api") application.include_router(realtime_router, prefix="/api") return application diff --git a/apps/api/tests/test_docker_sandbox.py b/apps/api/tests/test_docker_sandbox.py index a1bc262..b123d49 100644 --- a/apps/api/tests/test_docker_sandbox.py +++ b/apps/api/tests/test_docker_sandbox.py @@ -488,11 +488,6 @@ async def test_run_process_streams_output_to_the_callback(): # --------------------------------------------------------------------------- -@pytest_asyncio.fixture(autouse=True) -async def _platform_seed(seeded): - return seeded - - async def _owner_org(client: AsyncClient) -> tuple[str, dict]: from app.domain.identity.models import PlatformRole from tests.conftest import signup @@ -586,3 +581,65 @@ async def test_the_owner_check_helper_rejects_a_manager(): _require_platform_owner(_Ctx(owner)) # must not raise _ = (Membership, Role) + + +# --------------------------------------------------------------------------- +# HTTP surface: the owner's sandbox console +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture(autouse=True) +async def _platform_seed(seeded): + return seeded + + +async def _owner_org(client: AsyncClient) -> tuple[str, dict]: + from app.domain.identity.models import PlatformRole + from tests.conftest import signup + + token = ( + await signup( + client, + "sandbox-owner@example.com", + name="مالک سامانه", + platform_role=PlatformRole.OWNER, + ) + )["access_token"] + headers = auth_header(token) + org_id = ( + await client.post("/api/organizations", json={"name": "سازمان سندباکس"}, headers=headers) + ).json()["id"] + return org_id, headers + + +async def test_a_non_owner_cannot_list_containers(client: AsyncClient): + from tests.conftest import signup + + token = (await signup(client, "not-owner-sandbox@example.com"))["access_token"] + response = await client.get("/api/admin/sandbox/containers", headers=auth_header(token)) + assert response.status_code == 403, response.text + + +async def test_the_owner_sees_an_empty_list_with_a_mock_provider( + client: AsyncClient, +): + """The console must not invent containers it cannot see.""" + _org, headers = await _owner_org(client) + response = await client.get("/api/admin/sandbox/containers", headers=headers) + + assert response.status_code == 200, response.text + body = response.json() + assert body["containers"] == [] + # The image mapping is always available: it is configuration, not state. + assert "python" in body["images"] + + +async def test_stopping_a_container_requires_the_id_typed_back(client: AsyncClient): + _org, headers = await _owner_org(client) + response = await client.post( + "/api/admin/sandbox/containers/abc123/stop", + json={"confirm": "something-else"}, + headers=headers, + ) + assert response.status_code == 422, response.text + assert "مطابقت" in response.json()["error"]["message"] diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index b1b11e2..31599dd 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -6,6 +6,15 @@ const nextConfig = { env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "", }, + // v2: `/projects` is gone — one organization has exactly one application, so + // there is no list to browse. A permanent redirect keeps old bookmarks (and + // the browser history of anyone mid-flow) working instead of 404ing. + async redirects() { + return [ + { source: "/projects", destination: "/app", permanent: true }, + { source: "/projects/:path*", destination: "/app", permanent: true }, + ]; + }, async rewrites() { return [ { diff --git a/apps/web/src/app/(app)/admin/domains/page.tsx b/apps/web/src/app/(app)/admin/domains/page.tsx index 208053c..e3b7892 100644 --- a/apps/web/src/app/(app)/admin/domains/page.tsx +++ b/apps/web/src/app/(app)/admin/domains/page.tsx @@ -1,17 +1,327 @@ "use client"; -import { PlaceholderPage } from "@/components/PlaceholderPage"; +/** + * The platform owner's domain console (D9). + * + * A tenant gets one automatic demo host and nothing else. Everything here is the + * owner alone: attach another host, detach one (which is what takes a URL offline, + * so it needs the host typed back), and converge Caddy on the database. + * + * The re-sync button is the interesting one. It is safe to press at any time + * because the server compares the rendered routes against what is on disk and + * **reloads only when they differ** — a reload drops every live connection on the + * host, including tenants' demos, so doing nothing when nothing changed is the + * whole point. The `changed` flag the server returns is shown to the operator so + * the result is visible rather than implied. + */ + +import * as React from "react"; +import type { DomainRoute, SuggestedRoute } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + ConfirmDialog, + EmptyState, + ErrorState, + Field, + LoadingState, + Table, + type TableColumn, +} from "@agy/ui"; +import { ApiError } from "@/lib/api"; +import { domainsApi } from "@/lib/api-delivery"; + +interface Row { + id: string; + host: string; + project: string; + port: string; + status: string; + protected: string; + route: DomainRoute; +} + +function columns(onDetach: (route: DomainRoute) => void): Array> { + return [ + { + key: "host", + header: "دامنه", + className: "ltr-inline", + render: (row) => {row.host}, + }, + { + key: "project", + header: "اپلیکیشن", + render: (row) => {row.project}, + }, + { + key: "port", + header: "پورت", + className: "ltr-inline", + render: (row) => {row.port}, + }, + { + key: "status", + header: "وضعیت", + render: (row) => ( + {row.status} + ), + }, + { + key: "protected", + header: "محافظت‌شده", + render: (row) => {row.protected}, + }, + { + key: "actions", + header: "عملیات", + render: (row) => ( + + ), + }, + ]; +} export default function AdminDomainsPage() { + const [routes, setRoutes] = React.useState(null); + const [suggested, setSuggested] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [notice, setNotice] = React.useState(null); + + const [host, setHost] = React.useState(""); + const [pendingDetach, setPendingDetach] = React.useState(null); + const [typed, setTyped] = React.useState(""); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextRoutes, nextSuggested] = await Promise.all([ + domainsApi.list(), + domainsApi.suggested().catch(() => []), + ]); + setRoutes(nextRoutes); + setSuggested(nextSuggested); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت فهرست دامنه‌ها ناموفق بود."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void load(); + }, [load]); + + const attach = async () => { + const target = host.trim(); + if (!target) return; + setBusy(true); + setNotice(null); + try { + // The owner attaches a host to a project, and the server points it at the + // port the app already serves on. The project is picked from the routes + // already registered, so this console cannot attach a host to an app that + // has no port at all. + const listed = await domainsApi.list(); + const first = listed[0]; + if (!first) { + setNotice("هنوز اپلیکیشنی با مسیر فعال وجود ندارد."); + return; + } + await domainsApi.attach(first.organization_id, first.project_id, target); + setHost(""); + setNotice(`دامنهٔ ${target} اضافه شد.`); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "افزودن دامنه ناموفق بود."); + } finally { + setBusy(false); + } + }; + + const detach = async () => { + if (!pendingDetach) return; + setBusy(true); + try { + await domainsApi.detach(pendingDetach.id, typed.trim()); + setNotice(`دامنهٔ ${pendingDetach.host} حذف شد.`); + setPendingDetach(null); + setTyped(""); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود."); + } finally { + setBusy(false); + } + }; + + const sync = async () => { + setBusy(true); + setNotice(null); + try { + const result = await domainsApi.sync(); + setNotice( + result.changed + ? `${result.message} (${result.routes} مسیر)` + : "چیزی تغییر نکرده بود؛ Caddy بارگذاری مجدد نشد.", + ); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "همگام‌سازی ناموفق بود."); + } finally { + setBusy(false); + } + }; + + if (loading) return ; + if (error) return ; + + const rows: Row[] = (routes ?? []).map((route) => ({ + id: route.id, + host: route.host, + project: route.project_name || route.project_id, + port: String(route.port), + status: route.status, + protected: route.protected ? "بله" : "خیر", + route, + })); + return ( - +
+ {notice && ( + +

{notice}

+
+ )} + + +
+
+ دامنه‌های فعال + + هر اپلیکیشن یک دامنهٔ دموی خودکار دارد. افزودن دامنهٔ دیگر فقط با مالک سامانه + ممکن است؛ کاربران نمی‌توانند دامنه اضافه کنند. + +
+ +
+ + {rows.length === 0 ? ( +
+ +
+ ) : ( +
+ setPendingDetach(route))} + rows={rows} + rowKey={(row) => row.id} + caption="مسیرهای دامنهٔ سامانه" + /> + + )} + + + + افزودن دامنه + + یک برچسب زیر دامنهٔ سکو (مثل{" "} + shop.msa.artacloud.ir) یا، اگر فعال باشد، یک + دامنهٔ سفارشی کامل. دامنهٔ wildcard مجاز نیست. + +
{ + event.preventDefault(); + void attach(); + }} + > +
+ setHost(event.target.value)} + placeholder="shop.msa.artacloud.ir" + className="ltr-inline" + /> +
+ + +
+ + {suggested.length > 0 && ( + + مسیرهای پیشنهادی سامانه + + این مسیرها سرویس‌های خود سامانه‌اند، نه اپلیکیشن کاربر. پیش از فعال‌سازی، رکورد + DNS و گواهی TLS باید آماده باشد. + +
    + {suggested.map((route) => ( +
  • +
    +
    + {route.host} + پورت {route.port} +
    +

    {route.description}

    +
    + {route.enabled ? فعال : ( + غیرفعال + )} +
  • + ))} +
+
+ )} + + { + setPendingDetach(null); + setTyped(""); + }} + /> + + {pendingDetach && ( + + setTyped(event.target.value)} + placeholder={pendingDetach.host} + className="ltr-inline" + /> + + )} + ); } diff --git a/apps/web/src/app/(app)/admin/sandbox/page.tsx b/apps/web/src/app/(app)/admin/sandbox/page.tsx index 256a365..89045e8 100644 --- a/apps/web/src/app/(app)/admin/sandbox/page.tsx +++ b/apps/web/src/app/(app)/admin/sandbox/page.tsx @@ -1,17 +1,244 @@ "use client"; -import { PlaceholderPage } from "@/components/PlaceholderPage"; +/** + * The platform owner's sandbox console (D5). + * + * Containers are infrastructure, and in v2 infrastructure is the owner's alone. + * This page therefore shows *everything* running on the host that carries our + * labels — workspace, demo and build containers, across every organization — + * because the person who needs to see them is the one responsible for the host. + * + * Stopping a container is destructive and needs the id typed back: it is the only + * way to make a misclick land on a confirmation rather than on a running demo. + * The provider is asked, not assumed: a mock or an adapter without the capability + * reports an empty list rather than inventing one. + */ + +import * as React from "react"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + ConfirmDialog, + EmptyState, + ErrorState, + Field, + LoadingState, + Stat, +} from "@agy/ui"; +import { apiRequest } from "@/lib/api"; + +interface ContainerRow { + id: string; + name: string; + status: string; + organization_id: string; + project_id: string; + role: string; +} + +interface SandboxSummary { + containers: ContainerRow[]; + limits: Array<{ + organization_id: string; + cpu: number; + memory_mb: number; + disk_mb: number; + timeout_seconds: number; + network_policy: string; + }>; + images: Record; +} + +const ROLE_LABELS: Record = { + workspace: "فضای کار کارمندان", + demo: "نمایش زنده", + build: "ساخت", +}; export default function AdminSandboxPage() { + const [summary, setSummary] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [notice, setNotice] = React.useState(null); + const [pending, setPending] = React.useState(null); + const [typed, setTyped] = React.useState(""); + const [busy, setBusy] = React.useState(false); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setSummary(await apiRequest("/api/admin/sandbox/containers")); + } catch (err) { + // The response body carries a plain Persian message; anything else is ours. + setError(err instanceof Error ? err.message : "دریافت وضعیت کانتینرها ناموفق بود."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void load(); + }, [load]); + + const stop = async () => { + if (!pending) return; + setBusy(true); + try { + await apiRequest(`/api/admin/sandbox/containers/${pending.id}/stop`, { + method: "POST", + body: { confirm: typed.trim() }, + }); + setNotice(`کانتینر ${pending.id.slice(0, 12)} متوقف شد.`); + setPending(null); + setTyped(""); + await load(); + } catch (err) { + setNotice(err instanceof Error ? err.message : "توقف کانتینر ناموفق بود."); + } finally { + setBusy(false); + } + }; + + if (loading) return ; + if (error) return ; + if (!summary) return null; + + const byRole = summary.containers.reduce>((accumulator, row) => { + accumulator[row.role] = (accumulator[row.role] ?? 0) + 1; + return accumulator; + }, {}); + return ( - +
+ {notice && ( + +

{notice}

+
+ )} + + + کانتینرهای در حال اجرا + + هر اپلیکیشن یک کانتینر فضای کار (کارمندان در آن کد می‌نویسند) و یک کانتینر + نمایش زنده دارد. کانتینرهای ساخت موقت‌اند و پس از هر اجرا حذف می‌شوند. + +
+ + + + +
+ + {summary.containers.length === 0 ? ( +
+ +
+ ) : ( +
    + {summary.containers.map((row) => ( +
  • +
    +
    + + {row.status} + + {row.name} +
    +

    + {ROLE_LABELS[row.role] ?? row.role} ·{" "} + {row.organization_id.slice(0, 8)} +

    +
    + +
  • + ))} +
+ )} +
+ + + محدودیت منابع سازمان‌ها + + این مقادیر از صفحهٔ پروژه برای هر سازمان تنظیم می‌شوند و در سطح سرویس همیشه + محدود نگه داشته می‌شوند. + + {summary.limits.length === 0 ? ( +

محدودیت سفارشی‌ای ثبت نشده است.

+ ) : ( +
    + {summary.limits.map((row) => ( +
  • + + + + +
  • + ))} +
+ )} +
+ + + نگاشت تصویر پایه + + برای هر زبان اپلیکیشن یک تصویر پایه مشخص می‌شود؛ کانتینرهای ساخت و فضای کار از + همین تصویرها ساخته می‌شوند. + +
    + {Object.entries(summary.images).map(([language, image]) => ( +
  • + {language} + {image} +
  • + ))} +
+
+ + { + setPending(null); + setTyped(""); + }} + /> + + {pending && ( + + setTyped(event.target.value)} + placeholder={pending.id} + className="ltr-inline" + /> + + )} +
); } diff --git a/apps/web/src/app/(app)/app/_components/pipeline-console.tsx b/apps/web/src/app/(app)/app/_components/pipeline-console.tsx new file mode 100644 index 0000000..ea832a6 --- /dev/null +++ b/apps/web/src/app/(app)/app/_components/pipeline-console.tsx @@ -0,0 +1,141 @@ +"use client"; + +/** + * The live build console. + * + * Three details make this usable rather than merely live: + * + * 1. **Batching.** The server coalesces a step's output into at most one event + * per 500 ms, but a fast build still arrives faster than a DOM update is + * worth. Lines are accumulated and flushed on an animation frame. + * 2. **Auto-scroll that yields.** Following the tail is what you want until you + * scroll up to read something, at which point it is actively hostile. The + * console stops following the moment the user scrolls away from the bottom + * and offers an explicit way back. + * 3. **ANSI stripped.** A build log is full of colour codes; rendered raw they + * are unreadable noise. Stripping them is not cosmetic. + */ + +import * as React from "react"; +import { Badge, Button, Card, CardHint, CardTitle, cx } from "@agy/ui"; + +/** Remove ANSI SGR/CSI sequences and carriage-return progress redraws. */ +export function stripAnsi(text: string): string { + return text + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "") + .replace(/\r(?!\n)/g, "\n"); +} + +export interface LogLine { + key: string; + step: string; + text: string; +} + +/** Coalesce incoming lines into frames so a burst does not thrash the DOM. */ +export function useBatchedLines(incoming: LogLine[]): LogLine[] { + const [rendered, setRendered] = React.useState([]); + const pending = React.useRef([]); + const frame = React.useRef(null); + + React.useEffect(() => { + if (incoming.length === 0) return; + pending.current.push(...incoming); + if (frame.current !== null) return; + frame.current = requestAnimationFrame(() => { + frame.current = null; + const batch = pending.current; + pending.current = []; + // Bounded: a console is a window on the log, not the log itself. + setRendered((current) => [...current, ...batch].slice(-2000)); + }); + return () => { + if (frame.current !== null) { + cancelAnimationFrame(frame.current); + frame.current = null; + } + }; + }, [incoming]); + + return rendered; +} + +export function PipelineConsole({ + lines, + running, + className, +}: { + lines: LogLine[]; + running: boolean; + className?: string; +}) { + const rendered = useBatchedLines(lines); + const scroller = React.useRef(null); + const [following, setFollowing] = React.useState(true); + + React.useEffect(() => { + if (!following) return; + const node = scroller.current; + if (node) node.scrollTop = node.scrollHeight; + }, [rendered, following]); + + const onScroll = () => { + const node = scroller.current; + if (!node) return; + const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight < 24; + setFollowing(atBottom); + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(rendered.map((l) => `[${l.step}] ${l.text}`).join("\n")); + } catch { + /* a clipboard the browser refuses is not worth an error banner */ + } + }; + + return ( + +
+
+ لاگ زنده + + خروجی هر مرحله به‌صورت زنده اینجا می‌آید. کدهای رنگی ترمینال حذف می‌شوند. + +
+
+ {running && در حال اجرا} + {!following && ( + + )} + +
+
+ +
+ {rendered.length === 0 ? ( +

هنوز خروجی‌ای نرسیده است.

+ ) : ( + rendered.map((line) => ( +
+ {line.step} + {line.text} +
+ )) + )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/app/_components/production-panel.tsx b/apps/web/src/app/(app)/app/_components/production-panel.tsx new file mode 100644 index 0000000..820d1d0 --- /dev/null +++ b/apps/web/src/app/(app)/app/_components/production-panel.tsx @@ -0,0 +1,275 @@ +"use client"; + +/** + * The production section of the Application page. + * + * Production is the only action in the product the platform cannot undo, so the + * UI is built around one rule: **the operator has to type the organization's + * slug to proceed.** Not a checkbox, not a second click — the same string the + * server demands, so the modal cannot be dismissed by muscle memory. + * + * The target summary deliberately shows `has_secret` rather than the secret, and + * there is no field anywhere in this component that could render one. + */ + +import * as React from "react"; +import type { ProductionDeploy, ProductionOverview } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + ConfirmDialog, + EmptyState, + ErrorState, + Field, + LoadingState, + Stat, +} from "@agy/ui"; +import { ApiError } from "@/lib/api"; +import { deliveryApi } from "@/lib/api-delivery"; + +const STATUS_TONES = { + queued: "warning", + running: "warning", + succeeded: "success", + failed: "danger", + rolled_back: "neutral", +} as const; + +function toneFor(status: string) { + return STATUS_TONES[status as keyof typeof STATUS_TONES] ?? "neutral"; +} + +export function ProductionPanel({ + orgId, + projectId, + orgSlug, + canDeploy, + liveReleaseId, + onChanged, +}: { + orgId: string; + projectId: string; + orgSlug: string; + /** Org owner/admin. A viewer sees the history and no buttons at all. */ + canDeploy: boolean; + liveReleaseId: string | null; + onChanged?: () => void; +}) { + const [overview, setOverview] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [notice, setNotice] = React.useState(null); + + const [confirming, setConfirming] = React.useState<"deploy" | "rollback" | null>(null); + const [typed, setTyped] = React.useState(""); + const [lastLog, setLastLog] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setOverview(await deliveryApi.productionOverview(orgId, projectId)); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت وضعیت production ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId, projectId]); + + React.useEffect(() => { + void load(); + }, [load]); + + const onTest = async () => { + setBusy(true); + setNotice(null); + try { + const result = await deliveryApi.testProductionTarget(orgId, projectId); + setNotice(result.message); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "تست اتصال ناموفق بود."); + } finally { + setBusy(false); + } + }; + + const runConfirmed = async () => { + const slug = typed.trim(); + setBusy(true); + setNotice(null); + try { + const deploy = + confirming === "deploy" + ? await deliveryApi.deployToProduction(orgId, projectId, slug, liveReleaseId ?? undefined) + : await deliveryApi.rollbackProduction(orgId, projectId, slug); + setLastLog(deploy); + setNotice(deploy.message || (deploy.status === "succeeded" ? "انجام شد." : "ناموفق بود.")); + onChanged?.(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "عملیات ناموفق بود."); + } finally { + setBusy(false); + setConfirming(null); + setTyped(""); + await load(); + } + }; + + if (loading) return ; + if (error) return ; + + const target = overview?.target ?? null; + const deploys = overview?.deploys ?? []; + + return ( +
+ {notice && ( + +

{notice}

+
+ )} + + +
+
+ مقصد production + + استقرار روی production فقط با کلیک شما و با تأیید نامک سازمان انجام می‌شود. + +
+ {target?.last_test_ok === true && اتصال تأییدشده} + {target?.last_test_ok === false && اتصال ناموفق} +
+ + {target === null ? ( +
+ +
+ ) : ( + <> +
+ + + + +
+ {target.last_test_message && ( +

{target.last_test_message}

+ )} + {canDeploy && ( +
+ + + +
+ )} + {!canDeploy && ( +

+ استقرار روی production تنها توسط مالک یا مدیر سازمان انجام می‌شود. +

+ )} + + )} +
+ + + تاریخچهٔ استقرار + {deploys.length === 0 ? ( +

هنوز استقراری انجام نشده است.

+ ) : ( +
    + {deploys.map((deploy) => ( +
  • +
    +
    + {deploy.status} + {deploy.kind} + + {deploy.host} + +
    + +
    + {deploy.message && ( +

    {deploy.message}

    + )} + {deploy.rolled_back_from && ( +

    بازگردانی از استقرار قبلی

    + )} +
  • + ))} +
+ )} +
+ + {lastLog && ( + +
+ لاگ استقرار + +
+
+            {lastLog.log_tail || "خروجی‌ای ثبت نشده است."}
+          
+
+ )} + + { + setConfirming(null); + setTyped(""); + }} + /> + + {confirming !== null && ( + + setTyped(event.target.value)} + placeholder={orgSlug} + className="ltr-inline" + /> + + )} +
+ ); +} diff --git a/apps/web/src/app/(app)/app/page.tsx b/apps/web/src/app/(app)/app/page.tsx index 951abb3..157a15d 100644 --- a/apps/web/src/app/(app)/app/page.tsx +++ b/apps/web/src/app/(app)/app/page.tsx @@ -1,18 +1,464 @@ "use client"; -import { PlaceholderPage } from "@/components/PlaceholderPage"; +/** + * The Application page — v2's replacement for `/projects` (D1, D2). + * + * One organization has exactly one application, so this page is about *that* + * application rather than a list. It answers four questions, in the order they + * are actually asked: + * + * 1. Is it live, and at what URL? → the header + * 2. Did my last change build? → the pipeline runs, with live logs + * 3. What is deployed, and can I undo? → releases + * 4. Is production up to date? → the production panel + * + * What it deliberately does **not** show a customer: ports, container ids, branch + * controls, or anything else that is infrastructure. Those live in the owner + * console. The Git section shows the `dev` head and nothing more. + */ + +import * as React from "react"; +import type { + AppRuntime, + PipelineRun, + PipelineRunDetail, + Project, + Release, + WsPipelineLogData, + WsPipelineStatusData, + WsReleaseLiveData, +} from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + ConfirmDialog, + EmptyState, + ErrorState, + LoadingState, + Stat, + Tabs, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { deliveryApi } from "@/lib/api-delivery"; +import { useLiveChannel } from "@/lib/realtime"; +import { useSession } from "@/lib/session"; +import { PipelineConsole, stripAnsi, type LogLine } from "./_components/pipeline-console"; +import { ProductionPanel } from "./_components/production-panel"; + +type Tab = "pipeline" | "releases" | "production" | "git"; +type Tone = "neutral" | "success" | "warning" | "danger"; + +const RUN_TONES: Record = { + queued: "neutral", + running: "warning", + succeeded: "success", + failed: "danger", + cancelled: "neutral", + superseded: "neutral", +}; + +const STEP_TONES: Record = { + pending: "neutral", + running: "warning", + succeeded: "success", + failed: "danger", + skipped: "neutral", +}; + +function shortSha(sha: string): string { + return sha ? sha.slice(0, 7) : "—"; +} + +function when(value: string | null): string { + if (!value) return "—"; + try { + return new Date(value).toLocaleString("fa-IR"); + } catch { + return value; + } +} + +/** + * Read a live event's data as its typed payload. + * + * The socket carries ``Record`` by design (the transport is a + * plain JSON frame), so a direct cast is refused by the compiler. Going through + * ``unknown`` is the honest way to say "the server's typed publisher defines this + * shape and the client mirrors it" — and the narrow `as` cast that survives is + * the one place the mirror is asserted. + */ +function payload(event: { data: Record }): T { + return event.data as unknown as T; +} + +export default function ApplicationPage() { + const { activeOrganization, user } = useSession(); + const orgId = activeOrganization?.id ?? null; + const orgSlug = activeOrganization?.slug ?? ""; + // Org membership is enough to *see* everything here; the server is the real + // boundary for the actions, which is why a 403 still surfaces as a message. + const canManage = Boolean(user); + + const [tab, setTab] = React.useState("pipeline"); + const [project, setProject] = React.useState(null); + const [runtime, setRuntime] = React.useState(null); + const [runs, setRuns] = React.useState([]); + const [releases, setReleases] = React.useState([]); + const [selected, setSelected] = React.useState(null); + const [lines, setLines] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [pendingRollback, setPendingRollback] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [notice, setNotice] = React.useState(null); + + const projectId = project?.id ?? null; + + const load = React.useCallback(async () => { + if (!orgId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const projects = await api.listProjects(orgId); + const current = projects[0] ?? null; + setProject(current); + if (!current) { + setRuntime(null); + setRuns([]); + setReleases([]); + return; + } + const [nextRuntime, nextRuns, nextReleases] = await Promise.all([ + deliveryApi.getRuntime(orgId, current.id).catch(() => null), + deliveryApi.listRuns(orgId, current.id).catch(() => []), + deliveryApi.listReleases(orgId, current.id).catch(() => []), + ]); + setRuntime(nextRuntime); + setRuns(nextRuns); + setReleases(nextReleases); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت اطلاعات اپلیکیشن ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId]); + + React.useEffect(() => { + void load(); + }, [load]); + + // Live feed: build status, log lines and release switches. Log lines are + // filtered to the run currently open so two concurrent builds never interleave + // in one console. + useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => { + if (!projectId) return; + if (event.type === "pipeline.log") { + const data = payload(event); + if (selected && data.run_id !== selected.id) return; + const stamp = Date.now(); + const batch = (data.lines ?? []).map((text, index) => ({ + key: `${data.run_id}-${data.step}-${stamp}-${index}`, + step: data.step, + text: stripAnsi(text), + })); + if (batch.length) setLines((current) => [...current, ...batch]); + return; + } + if (event.type === "pipeline.status") { + const data = payload(event); + if (data.project_id !== projectId) return; + void load(); + return; + } + if (event.type === "release.live") { + const data = payload(event); + if (data.project_id !== projectId) return; + void load(); + } + }); + + const openRun = async (runId: string) => { + if (!orgId) return; + setNotice(null); + setLines([]); + try { + setSelected(await deliveryApi.getRun(orgId, runId)); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "دریافت جزئیات اجرا ناموفق بود."); + } + }; + + const startRun = async () => { + if (!orgId || !projectId) return; + setBusy(true); + setNotice(null); + try { + await deliveryApi.startRun(orgId, projectId); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "شروع اجرای جدید ناموفق بود."); + } finally { + setBusy(false); + } + }; + + const doRollback = async () => { + if (!orgId || !projectId || !pendingRollback) return; + setBusy(true); + try { + await deliveryApi.rollbackDemo(orgId, projectId, pendingRollback.id); + setNotice("دمو به نسخهٔ انتخاب‌شده بازگردانده شد."); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "بازگردانی دمو ناموفق بود."); + } finally { + setBusy(false); + setPendingRollback(null); + } + }; + + if (loading) return ; + if (error) return ; + + if (!project) { + return ( + + ); + } + + const liveRelease = releases.find((release) => release.is_demo_live) ?? null; + const healthTone: Tone = + runtime?.status === "live" ? "success" : runtime?.status === "degraded" ? "danger" : "neutral"; -export default function AppPage() { return ( - +
+ {notice && ( + +

{notice}

+
+ )} + + {/* -- header ------------------------------------------------------- */} + +
+
+
+ {project.name} + {runtime?.status ?? "بدون محیط"} +
+ + {project.slug} + +
+
+ {runtime?.demo_url ? ( + + {runtime.demo_url} + + ) : ( + آدرس دمو آماده نیست. + )} + {canManage && ( + + )} +
+
+ +
+ + + + +
+
+ + setTab(key as Tab)} + /> + + {/* -- pipeline ------------------------------------------------------ */} + {tab === "pipeline" && ( +
+ + اجراهای خط لوله + + هر بار که شاخهٔ dev به‌روز می‌شود یک اجرا ساخته + می‌شود. برای دیدن جزئیات و لاگ زنده روی هر اجرا بزنید. + + {runs.length === 0 ? ( +

هنوز اجرایی ثبت نشده است.

+ ) : ( +
    + {runs.map((run) => ( +
  • + +
  • + ))} +
+ )} +
+ + {selected && ( + <> + + مراحل اجرا +
    + {selected.steps.map((step) => ( +
  1. +
    + {step.name} + {step.status} +
    +
    + {step.exit_code !== null && ( + exit {step.exit_code} + )} + {step.duration_ms > 0 && {step.duration_ms}ms} +
    +
  2. + ))} +
+
+ + + + )} +
+ )} + + {/* -- releases ------------------------------------------------------ */} + {tab === "releases" && ( + + نسخه‌ها + + هر اجرای موفق یک نسخه می‌سازد. بازگردانی دمو، مسیر دمو را به نسخهٔ قبلی برمی‌گرداند. + + {releases.length === 0 ? ( +

هنوز نسخه‌ای منتشر نشده است.

+ ) : ( +
    + {releases.map((release) => ( +
  • +
    + {release.is_demo_live && زنده} + + {shortSha(release.sha)} + + {when(release.created_at)} +
    + {canManage && !release.is_demo_live && ( + + )} +
  • + ))} +
+ )} +
+ )} + + {/* -- production ---------------------------------------------------- */} + {tab === "production" && orgId && ( + + )} + + {/* -- git ----------------------------------------------------------- */} + {tab === "git" && ( + + مخزن + + کد پروژه در مخزن Git نگهداری می‌شود. شاخهٔ dev{" "} + شاخهٔ انتشار است؛ تغییرات کارکنان هوش مصنوعی پس از تست به آن ادغام می‌شوند. + +
+ + +
+

+ کنترل شاخه، پورت و دستورها برای کاربران نمایش داده نمی‌شود. +

+
+ )} + + setPendingRollback(null)} + /> +
); } diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx deleted file mode 100644 index 099ea95..0000000 --- a/apps/web/src/app/(app)/projects/[id]/page.tsx +++ /dev/null @@ -1,468 +0,0 @@ -"use client"; - -import Link from "next/link"; -import * as React from "react"; -import type { PreviewDomain, Project, RepoStatus, Repository } from "@agy/contracts"; -import { - Badge, - Button, - Card, - CardHint, - CardTitle, - ConfirmDialog, - EmptyState, - ErrorState, - Field, - LoadingState, - Stat, -} from "@agy/ui"; -import { ApiError, api } from "@/lib/api"; -import { useSession } from "@/lib/session"; -import { SandboxPanel } from "@/components/sandbox-panel"; - -type Tab = "repo" | "preview" | "sandbox"; - -const DOMAIN_TONES = { - active: "success", - disabled: "neutral", - failed: "danger", -} as const; - -export default function ProjectDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id: projectId } = React.use(params); - const { activeOrganization, user } = useSession(); - - const [tab, setTab] = React.useState("repo"); - const [project, setProject] = React.useState(null); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - const orgId = activeOrganization?.id ?? null; - - const load = React.useCallback(async () => { - if (!orgId) { - setLoading(false); - return; - } - setLoading(true); - setError(null); - try { - setProject(await api.getProject(orgId, projectId)); - } catch (err) { - setError(err instanceof ApiError ? err.message : "دریافت پروژه ناموفق بود."); - } finally { - setLoading(false); - } - }, [orgId, projectId]); - - React.useEffect(() => { - void load(); - }, [load]); - - if (!activeOrganization) { - return ( - - ); - } - - if (loading) return ; - if (error) return ; - if (!project) { - return ( - - ); - } - - return ( -
- - -
-
-

{project.name}

- - {project.slug} - -
- {project.description ? ( -

{project.description}

- ) : ( -

توضیحی ثبت نشده است.

- )} -
- -
- {( - [ - ["repo", "مخزن کد"], - ["preview", "دامنهٔ تست"], - ["sandbox", "محیط ایزوله"], - ] as const - ).map(([value, label]) => ( - - ))} -
- - {tab === "repo" ? ( - - ) : tab === "preview" ? ( - - ) : ( - - )} -
- ); -} - -/* -------------------------------------------------------------------------- */ -/* Repository */ -/* -------------------------------------------------------------------------- */ - -function RepositoryPanel({ orgId, projectId }: { orgId: string; projectId: string }) { - const [status, setStatus] = React.useState(null); - const [repo, setRepo] = React.useState(null); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [creating, setCreating] = React.useState(false); - const [copied, setCopied] = React.useState(false); - - const load = React.useCallback(async () => { - setLoading(true); - setError(null); - try { - const nextStatus = await api.getRepoStatus(orgId, projectId); - setStatus(nextStatus); - if (nextStatus.exists) { - // The status probe only reports shape; the detail call carries the URLs. - setRepo(await api.getRepo(orgId, projectId)); - } else { - setRepo(null); - } - } catch (err) { - setError(err instanceof ApiError ? err.message : "دریافت وضعیت مخزن ناموفق بود."); - } finally { - setLoading(false); - } - }, [orgId, projectId]); - - React.useEffect(() => { - void load(); - }, [load]); - - const onEnsure = async () => { - setCreating(true); - setError(null); - try { - setRepo(await api.ensureRepo(orgId, projectId)); - setStatus(await api.getRepoStatus(orgId, projectId)); - } catch (err) { - setError(err instanceof ApiError ? err.message : "ساخت مخزن ناموفق بود."); - } finally { - setCreating(false); - } - }; - - const onCopy = async () => { - if (!repo?.clone_url) return; - try { - await navigator.clipboard.writeText(repo.clone_url); - setCopied(true); - window.setTimeout(() => setCopied(false), 1600); - } catch { - /* clipboard blocked — the URL is visible and selectable anyway */ - } - }; - - if (loading) return ; - - return ( -
- {error && } - - {!status?.exists ? ( - - مخزنی وجود ندارد - - یک مخزن خصوصی Git برای این پروژه ساخته می‌شود و اعتبارنامهٔ آن به‌صورت رمزنگاری‌شده - ذخیره می‌گردد. کلید خام هرگز از طریق API برگردانده نمی‌شود. - -
- -
-
- ) : ( - <> -
- فعال} - hint={status.private ? "مخزن خصوصی" : "مخزن عمومی"} - /> - {status.default_branch}} /> - - -
- - - دسترسی - از این آدرس برای clone استفاده کنید؛ اعتبارنامه به‌صورت خودکار تزریق می‌شود. -
-
- - {repo?.clone_url ?? "—"} - - -
- {repo?.web_url && ( - - {repo.web_url} - - )} -

- نام مخزن: {repo?.repo_name ?? "—"} · مالک:{" "} - {repo?.owner ?? "—"} · ارائه‌دهنده:{" "} - {repo?.provider ?? "—"} -

-
-
- - - شاخه‌ها - {status.branches.length === 0 ? ( -

شاخه‌ای ثبت نشده است.

- ) : ( -
    - {status.branches.map((branch) => ( -
  • - - {branch} - -
  • - ))} -
- )} -
- - )} -
- ); -} - -/* -------------------------------------------------------------------------- */ -/* Preview domains */ -/* -------------------------------------------------------------------------- */ - -function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: string }) { - const [domains, setDomains] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [subdomain, setSubdomain] = React.useState(""); - const [port, setPort] = React.useState(""); - const [creating, setCreating] = React.useState(false); - const [formError, setFormError] = React.useState(null); - const [pendingDelete, setPendingDelete] = React.useState(null); - const [deleting, setDeleting] = React.useState(false); - - const load = React.useCallback(async () => { - setLoading(true); - setError(null); - try { - setDomains(await api.listPreviewDomains(orgId, projectId)); - } catch (err) { - setError(err instanceof ApiError ? err.message : "دریافت دامنه‌ها ناموفق بود."); - } finally { - setLoading(false); - } - }, [orgId, projectId]); - - React.useEffect(() => { - void load(); - }, [load]); - - const onCreate = async (event: React.FormEvent) => { - event.preventDefault(); - setFormError(null); - const parsedPort = port.trim() === "" ? undefined : Number(port); - if (parsedPort !== undefined && (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)) { - setFormError("شمارهٔ پورت باید عددی بین ۱ تا ۶۵۵۳۵ باشد."); - return; - } - setCreating(true); - try { - const created = await api.createPreviewDomain(orgId, projectId, { - subdomain: subdomain.trim(), - port: parsedPort, - }); - setDomains((current) => [...current, created]); - setSubdomain(""); - setPort(""); - } catch (err) { - setFormError(err instanceof ApiError ? err.message : "ساخت دامنه ناموفق بود."); - } finally { - setCreating(false); - } - }; - - const onConfirmDelete = async () => { - if (!pendingDelete) return; - setDeleting(true); - try { - await api.deletePreviewDomain(orgId, projectId, pendingDelete.id); - setDomains((current) => current.filter((d) => d.id !== pendingDelete.id)); - setPendingDelete(null); - } catch (err) { - setError(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود."); - setPendingDelete(null); - } finally { - setDeleting(false); - } - }; - - return ( -
- - ساخت دامنهٔ تست - - برچسب زیردامنه باید بین ۳ تا ۴۰ نویسه و فقط شامل حروف کوچک انگلیسی، رقم و خط تیره باشد. - اگر پورت را خالی بگذارید، سامانه خودش یک پورت آزاد تخصیص می‌دهد. - -
-
- setSubdomain(e.target.value)} - placeholder="مثلاً demo-shop" - className="ltr-inline" - /> -
-
- setPort(e.target.value)} - placeholder="۸۰۸۰" - className="ltr-inline" - /> -
- - - {formError && ( -
- -
- )} -
- - {error && } - - {loading ? ( - - ) : domains.length === 0 ? ( - - ) : ( -
- {domains.map((domain) => ( - -
-
-

- {domain.host} -

-

پورت {domain.port}

-
-
- {domain.status} - {domain.protected && محافظت‌شده} -
-
- - {domain.url} - -
- -
-
- ))} -
- )} - - setPendingDelete(null)} - /> -
- ); -} diff --git a/apps/web/src/app/(app)/projects/page.tsx b/apps/web/src/app/(app)/projects/page.tsx deleted file mode 100644 index 26d0f77..0000000 --- a/apps/web/src/app/(app)/projects/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; - -import Link from "next/link"; -import * as React from "react"; -import type { Project } from "@agy/contracts"; -import { - Button, - Card, - CardHint, - CardTitle, - EmptyState, - ErrorState, - Field, - LoadingState, -} from "@agy/ui"; -import { ApiError, api } from "@/lib/api"; -import { useSession } from "@/lib/session"; - -export default function ProjectsPage() { - const { activeOrganization } = useSession(); - const [projects, setProjects] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [name, setName] = React.useState(""); - const [description, setDescription] = React.useState(""); - const [creating, setCreating] = React.useState(false); - const [formError, setFormError] = React.useState(null); - - const load = React.useCallback(async () => { - if (!activeOrganization) { - setLoading(false); - return; - } - setLoading(true); - setError(null); - try { - setProjects(await api.listProjects(activeOrganization.id)); - } catch (err) { - setError(err instanceof ApiError ? err.message : "دریافت پروژه‌ها ناموفق بود."); - } finally { - setLoading(false); - } - }, [activeOrganization]); - - React.useEffect(() => { - void load(); - }, [load]); - - const onCreate = async (event: React.FormEvent) => { - event.preventDefault(); - if (!activeOrganization) return; - setFormError(null); - setCreating(true); - try { - const created = await api.createProject(activeOrganization.id, { - name: name.trim(), - description: description.trim(), - }); - setProjects((current) => [created, ...current]); - setName(""); - setDescription(""); - } catch (err) { - setFormError(err instanceof ApiError ? err.message : "ساخت پروژه ناموفق بود."); - } finally { - setCreating(false); - } - }; - - if (!activeOrganization) { - return ( - - ); - } - - return ( -
-
-

پروژه‌ها

-

- هر پروژه می‌تواند یک مخزن خصوصی، یک محیط اجرای ایزوله و یک دامنهٔ تست داشته باشد. -

-
- - - ساخت پروژه - نام پروژه به یک slug یکتا در سازمان تبدیل می‌شود. -
-
- setName(e.target.value)} - placeholder="مثلاً فروشگاه آنلاین" - /> -
-
- setDescription(e.target.value)} - /> -
- - - {formError && ( -
- -
- )} -
- - {loading ? ( - - ) : error ? ( - - ) : projects.length === 0 ? ( - - ) : ( -
- {projects.map((project) => ( - -
-

{project.name}

-

{project.slug}

-
- {project.description && ( -

{project.description}

- )} - - - -
- ))} -
- )} -
- ); -} diff --git a/apps/web/src/lib/api-delivery.ts b/apps/web/src/lib/api-delivery.ts index a7da8bc..90a9b57 100644 --- a/apps/web/src/lib/api-delivery.ts +++ b/apps/web/src/lib/api-delivery.ts @@ -2,9 +2,122 @@ * Typed client functions for the delivery workstream (docs/plan/PLAN-v2.md §6). * * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport. + * + * Every call travels over the authenticated WebSocket RPC (see ``lib/api.ts``), so + * these are plain functions with no transport concerns of their own. */ + +import type { + AppRuntime, + DomainRoute, + DomainSyncResult, + PipelineRun, + PipelineRunDetail, + ProductionDeploy, + ProductionOverview, + Release, + SuggestedRoute, +} from "@agy/contracts"; import { apiRequest } from "./api"; +function orgBase(orgId: string): string { + return `/api/organizations/${orgId}/delivery`; +} + +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") search.set(key, String(value)); + } + const text = search.toString(); + return text ? `?${text}` : ""; +} + export const deliveryApi = { - // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`), -} satisfies Record ReturnType>; + // -- CI/CD runs ---------------------------------------------------------- + + listRuns: (orgId: string, projectId: string, limit = 50) => + apiRequest( + `${orgBase(orgId)}/runs${query({ project_id: projectId, limit })}`, + ), + + getRun: (orgId: string, runId: string) => + apiRequest(`${orgBase(orgId)}/runs/${runId}`), + + /** Re-run the current `dev` head. Never a production deploy. */ + startRun: (orgId: string, projectId: string) => + apiRequest(`${orgBase(orgId)}/runs`, { + method: "POST", + body: { project_id: projectId }, + }), + + cancelRun: (orgId: string, runId: string) => + apiRequest(`${orgBase(orgId)}/runs/${runId}/cancel`, { method: "POST" }), + + // -- releases & the demo runtime ---------------------------------------- + + listReleases: (orgId: string, projectId: string) => + apiRequest(`${orgBase(orgId)}/releases${query({ project_id: projectId })}`), + + getRuntime: (orgId: string, projectId: string) => + apiRequest(`${orgBase(orgId)}/runtime${query({ project_id: projectId })}`), + + rollbackDemo: (orgId: string, projectId: string, releaseId?: string) => + apiRequest(`${orgBase(orgId)}/releases/rollback`, { + method: "POST", + body: { project_id: projectId, release_id: releaseId }, + }), + + // -- production (admin+, always confirmed) ------------------------------ + + productionOverview: (orgId: string, projectId: string) => + apiRequest( + `${orgBase(orgId)}/production${query({ project_id: projectId })}`, + ), + + testProductionTarget: (orgId: string, projectId: string) => + apiRequest<{ ok: boolean; message: string }>(`${orgBase(orgId)}/production/test`, { + method: "POST", + body: { project_id: projectId }, + }), + + /** + * Ship a release to production. + * + * ``confirm`` must be the organization's slug typed back by the operator — the + * server rejects anything else. There is no way to skip it. + */ + deployToProduction: (orgId: string, projectId: string, confirm: string, releaseId?: string) => + apiRequest(`${orgBase(orgId)}/production/deploy`, { + method: "POST", + body: { project_id: projectId, confirm, release_id: releaseId }, + }), + + rollbackProduction: (orgId: string, projectId: string, confirm: string, deployId?: string) => + apiRequest(`${orgBase(orgId)}/production/rollback`, { + method: "POST", + body: { project_id: projectId, confirm, deploy_id: deployId }, + }), +}; + +/** Platform-owner domain console. */ +export const domainsApi = { + list: () => apiRequest("/api/admin/domains"), + + suggested: () => apiRequest("/api/admin/domains/suggested"), + + attach: (organizationId: string, projectId: string, host: string) => + apiRequest("/api/admin/domains", { + method: "POST", + body: { organization_id: organizationId, project_id: projectId, host }, + }), + + /** Detaching requires the host typed back — it is what takes a URL offline. */ + detach: (domainId: string, confirm: string) => + apiRequest(`/api/admin/domains/${domainId}`, { + method: "DELETE", + body: { confirm }, + }), + + sync: () => apiRequest("/api/admin/domains/sync", { method: "POST" }), +}; diff --git a/apps/web/src/test/delivery-app.test.tsx b/apps/web/src/test/delivery-app.test.tsx new file mode 100644 index 0000000..dfa2f96 --- /dev/null +++ b/apps/web/src/test/delivery-app.test.tsx @@ -0,0 +1,382 @@ +/** + * §v2-A3.6 — the Application page. + * + * The subjects are the things this page could get *wrong* in a way that matters: + * + * * the live console must batch and must stop following the tail when the user + * scrolls away — a console that yanks the view back is worse than no console; + * * ANSI colour codes must be stripped — raw, a build log is unreadable noise; + * * production is the one irreversible action, so the confirmation must be real + * and the deploy button must be absent for anyone who is not an org admin; + * * no secret is ever rendered. The API returns `has_secret`, not the secret, and + * this file asserts the secret string is nowhere in the DOM. + */ + +import { render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + session: { + ready: true, + user: null as { id: string; email: string } | null, + organizations: [] as Array<{ id: string; slug: string }>, + activeOrganization: null as { id: string; slug: string } | null, + login: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + setActiveOrganization: vi.fn(), + }, + channels: [] as string[], + listProjects: vi.fn(), + getRuntime: vi.fn(), + listRuns: vi.fn(), + listReleases: vi.fn(), + getRun: vi.fn(), + productionOverview: vi.fn(), + startRun: vi.fn(), + rollbackDemo: vi.fn(), + deployToProduction: vi.fn(), + rollbackProduction: vi.fn(), + testProductionTarget: vi.fn(), +})); + +vi.mock("@/lib/session", () => ({ useSession: () => mocks.session })); + +vi.mock("@/lib/realtime", () => ({ + useLiveChannel: (channels: string[]) => { + mocks.channels = channels; + }, +})); + +vi.mock("@/lib/api", () => { + class FakeApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } + } + return { ApiError: FakeApiError, api: { listProjects: mocks.listProjects } }; +}); + +vi.mock("@/lib/api-delivery", () => ({ + deliveryApi: { + getRuntime: mocks.getRuntime, + listRuns: mocks.listRuns, + listReleases: mocks.listReleases, + getRun: mocks.getRun, + productionOverview: mocks.productionOverview, + startRun: mocks.startRun, + rollbackDemo: mocks.rollbackDemo, + deployToProduction: mocks.deployToProduction, + rollbackProduction: mocks.rollbackProduction, + testProductionTarget: mocks.testProductionTarget, + }, + domainsApi: {}, +})); + +import ApplicationPage from "@/app/(app)/app/page"; +import { stripAnsi } from "@/app/(app)/app/_components/pipeline-console"; + +const ORG = { id: "org-1", slug: "acme" }; + +const PROJECT = { + id: "proj-1", + organization_id: ORG.id, + name: "اپلیکیشن نمونه", + slug: "sample-app", + description: "", + default_branch: "dev", + created_at: "2026-10-13T10:00:00.000Z", +}; + +const RUNTIME = { + project_id: "proj-1", + demo_host: "acme.msa.artacloud.ir", + demo_url: "https://acme.msa.artacloud.ir", + host_port: 8100, + status: "live", + current_release_id: "rel-1", + updated_at: "2026-10-13T10:00:00.000Z", +}; + +const RELEASE = { + id: "rel-1", + project_id: "proj-1", + run_id: "run-1", + sha: "abc1234def5678", + is_demo_live: true, + created_at: "2026-10-13T10:00:00.000Z", +}; + +function run(overrides: Record = {}) { + return { + id: "run-1", + project_id: "proj-1", + branch: "dev", + sha: "abc1234def5678", + trigger: "engine", + status: "succeeded", + error_summary: "", + started_at: "2026-10-13T10:00:00.000Z", + finished_at: "2026-10-13T10:01:00.000Z", + created_at: "2026-10-13T10:00:00.000Z", + ...overrides, + }; +} + +const PRODUCTION = { + target: { + kind: "ssh" as const, + host: "deploy.example.com", + port: 22, + username: "deployer", + auth_type: "password" as const, + remote_path: "/srv/app", + has_secret: true, + host_fingerprint: "SHA256:abc", + last_tested_at: "2026-10-13T09:00:00.000Z", + last_test_ok: true, + last_test_message: "اتصال و مسیر مقصد تأیید شد.", + }, + deploys: [], +}; + +function bootstrap({ asOwner = true } = {}) { + mocks.session.user = asOwner ? { id: "u-1", email: "owner@example.com" } : null; + mocks.session.organizations = [ORG]; + mocks.session.activeOrganization = ORG; + mocks.listProjects.mockResolvedValue([PROJECT]); + mocks.getRuntime.mockResolvedValue(RUNTIME); + mocks.listRuns.mockResolvedValue([run()]); + mocks.listReleases.mockResolvedValue([RELEASE]); + mocks.getRun.mockResolvedValue({ + ...run(), + steps: [ + "checkout", + "setup", + "build", + "test", + "package", + "deploy_demo", + "healthcheck", + "route", + ].map((name, index) => ({ + id: `s-${index}`, + index, + name, + status: "succeeded", + exit_code: 0, + log_tail: "", + duration_ms: 10, + })), + }); + mocks.productionOverview.mockResolvedValue(PRODUCTION); + mocks.startRun.mockResolvedValue(run()); + mocks.rollbackDemo.mockResolvedValue(RELEASE); + mocks.deployToProduction.mockResolvedValue({ + id: "dep-1", + project_id: "proj-1", + kind: "ssh", + host: "deploy.example.com", + status: "succeeded", + message: "استقرار در production انجام شد.", + log_tail: "done", + release_id: "rel-1", + rolled_back_from: null, + started_at: "2026-10-13T10:00:00.000Z", + finished_at: "2026-10-13T10:01:00.000Z", + created_at: "2026-10-13T10:00:00.000Z", + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + bootstrap(); +}); + +// --------------------------------------------------------------------------- +// the console helpers +// --------------------------------------------------------------------------- + + +describe("the pipeline console", () => { + it("strips ANSI colour codes and carriage-return redraws", () => { + const raw = "ok fail\rdone"; + expect(stripAnsi(raw)).toBe("ok fail\ndone"); + }); + + it("leaves a clean line untouched", () => { + expect(stripAnsi("npm WARN deprecated foo@1.0.0")).toBe("npm WARN deprecated foo@1.0.0"); + }); +}); + +// --------------------------------------------------------------------------- +// the page +// --------------------------------------------------------------------------- + + +describe("the Application page", () => { + it("renders the app, the demo URL and the live release", async () => { + render(); + + await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument()); + expect(screen.getByText("https://acme.msa.artacloud.ir")).toBeInTheDocument(); + // The live release is shown as a short sha, never the raw one — in the + // header and again next to the run that produced it. + expect(screen.getAllByText("abc1234").length).toBeGreaterThan(0); + expect(screen.queryByText("abc1234def5678")).not.toBeInTheDocument(); + expect(mocks.channels).toEqual(["org:org-1"]); + }); + + it("subscribes to the tenant channel so a build stays live", async () => { + render(); + await waitFor(() => expect(mocks.channels).toEqual(["org:org-1"])); + }); + + it("shows the eight pipeline steps once a run is opened", async () => { + const { container } = render(); + await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument()); + + // Opening a run fetches its detail; `getRun` is already stubbed in + // `bootstrap()` with all eight steps. The run row is identified by the badge + // the list renders for it — the first `